diff --git a/configs/content_quality_contracts.json b/configs/content_quality_contracts.json new file mode 100644 index 0000000..2e6a210 --- /dev/null +++ b/configs/content_quality_contracts.json @@ -0,0 +1,311 @@ +{ + "config_version": "content_quality_contracts_v1", + "rolling_window_size": 5, + "full_chain_enforcement": true, + "generation_hard_constraints": { + "config_version": "generation_hard_constraints_v1", + "repair_policy": "repair_once_then_fail_closed", + "universal_rules": { + "schema_complete": { + "issue_code": "Q10", + "action": "block", + "summary": "Reader chapter payload must include non-empty title, body, and branch choices." + }, + "broken_slot": { + "issue_code": "Q10", + "action": "block", + "summary": "Template slot fragments cannot reach persisted reader prose." + }, + "engineering_leak": { + "issue_code": "Q01", + "action": "block", + "summary": "Reader-visible prose cannot expose engine fields, ids, or route notation." + }, + "meta_narration_leak": { + "issue_code": "Q02", + "action": "block", + "summary": "Reader-visible prose cannot explain chapter construction or planning intent." + }, + "grounding_failed": { + "issue_code": "Q07", + "action": "block", + "summary": "Failed grounding cannot be persisted as a passed quality result." + }, + "premature_terminal": { + "issue_code": "Q09", + "action": "block", + "summary": "Premature terminal chapters are blocked before the configured route runway." + }, + "stock_refrain_budget": { + "issue_code": "Q03", + "action": "block", + "summary": "Known long-route refrain phrases must stay under deterministic budgets." + }, + "choice_text_budget": { + "issue_code": "Q08", + "action": "block", + "summary": "Branch choices must remain non-empty and distinct within the current chapter." + } + }, + "base_thresholds": { + "min_choice_count": 2, + "min_body_text_units": 80, + "stock_refrain_current_max": 2, + "choice_text_current_max": 1 + }, + "genre_profiles": { + "mystery": { + "aliases": ["urban_mystery", "detective", "suspense"], + "threshold_overrides": { + "stock_refrain_current_max": 2 + } + }, + "romance": { + "aliases": ["romance", "relationship"], + "threshold_overrides": { + "choice_text_current_max": 1 + } + }, + "fantasy": { + "aliases": ["fantasy", "xianxia", "wuxia"], + "threshold_overrides": {} + }, + "realist": { + "aliases": ["realist", "contemporary", "slice_of_life"], + "threshold_overrides": {} + }, + "light_novel": { + "aliases": ["light_novel", "web_serial"], + "threshold_overrides": { + "min_choice_count": 2 + } + } + }, + "length_profiles": { + "long_route_30": { + "min_chapters": 30, + "threshold_overrides": { + "stock_refrain_current_max": 2 + } + }, + "long_route_50": { + "min_chapters": 50, + "threshold_overrides": { + "stock_refrain_current_max": 2 + } + } + } + }, + "bands": { + "100": { + "enabled": true, + "diagnostic_enabled": true, + "gate_enforced": true, + "thresholds": { + "repetition_score_max": 0.2, + "exposition_ratio_max": 0.52, + "concrete_detail_density_min": 0.04, + "dialogue_plus_action_ratio_min": 0.42, + "late_window_hook_quality_min": 0.85, + "q09_pre_end_max": 0.08 + }, + "windows": { + "early": { + "start": 1, + "end": 10, + "q03_q04_combined_breach_share_max": 0.45 + }, + "mid": { + "start": 30, + "end": 60, + "repetition_breach_rate_max": 0.3, + "exposition_breach_rate_max": 0.3, + "detail_breach_rate_max": 0.35 + }, + "late": { + "start": 80, + "end": 100, + "q09_breach_rate_max": 0.08, + "detail_breach_rate_max": 0.35, + "premature_terminal_forbidden": true + } + }, + "enforcement_policy": { + "Q03": "rewrite", + "Q04": "rewrite", + "Q05": "rewrite", + "Q09_pre_end": "block", + "rolling_repeat_escalation": "block", + "rolling_exposition_escalation": "block" + } + }, + "200": { + "enabled": false, + "diagnostic_enabled": true, + "gate_enforced": false, + "thresholds": { + "repetition_score_max": 0.18, + "exposition_ratio_max": 0.5, + "concrete_detail_density_min": 0.045, + "dialogue_plus_action_ratio_min": 0.46, + "late_window_hook_quality_min": 0.86, + "q09_pre_end_max": 0.1 + }, + "windows": { + "early": { + "start": 1, + "end": 20, + "q03_q04_combined_breach_share_max": 0.4 + }, + "mid": { + "start": 60, + "end": 140, + "repetition_breach_rate_max": 0.28, + "exposition_breach_rate_max": 0.28, + "detail_breach_rate_max": 0.32 + }, + "late": { + "start": 160, + "end": 200, + "q09_breach_rate_max": 0.12, + "detail_breach_rate_max": 0.32, + "premature_terminal_forbidden": true + } + }, + "enforcement_policy": { + "Q03": "rewrite", + "Q04": "rewrite", + "Q05": "rewrite", + "Q09_pre_end": "block", + "rolling_repeat_escalation": "block", + "rolling_exposition_escalation": "block" + } + }, + "250": { + "enabled": false, + "diagnostic_enabled": false, + "gate_enforced": false, + "thresholds": { + "repetition_score_max": 0.2, + "exposition_ratio_max": 0.52, + "concrete_detail_density_min": 0.04, + "dialogue_plus_action_ratio_min": 0.42, + "late_window_hook_quality_min": 0.85, + "q09_pre_end_max": 0.08 + }, + "windows": { + "early": { + "start": 1, + "end": 10, + "q03_q04_combined_breach_share_max": 0.45 + }, + "mid": { + "start": 30, + "end": 60, + "repetition_breach_rate_max": 0.3, + "exposition_breach_rate_max": 0.3, + "detail_breach_rate_max": 0.35 + }, + "late": { + "start": 80, + "end": 100, + "q09_breach_rate_max": 0.08, + "detail_breach_rate_max": 0.35, + "premature_terminal_forbidden": true + } + }, + "enforcement_policy": { + "Q03": "rewrite", + "Q04": "rewrite", + "Q05": "rewrite", + "Q09_pre_end": "block", + "rolling_repeat_escalation": "block", + "rolling_exposition_escalation": "block" + } + }, + "500": { + "enabled": false, + "diagnostic_enabled": false, + "gate_enforced": false, + "thresholds": { + "repetition_score_max": 0.2, + "exposition_ratio_max": 0.52, + "concrete_detail_density_min": 0.04, + "dialogue_plus_action_ratio_min": 0.42, + "late_window_hook_quality_min": 0.85, + "q09_pre_end_max": 0.08 + }, + "windows": { + "early": { + "start": 1, + "end": 10, + "q03_q04_combined_breach_share_max": 0.45 + }, + "mid": { + "start": 30, + "end": 60, + "repetition_breach_rate_max": 0.3, + "exposition_breach_rate_max": 0.3, + "detail_breach_rate_max": 0.35 + }, + "late": { + "start": 80, + "end": 100, + "q09_breach_rate_max": 0.08, + "detail_breach_rate_max": 0.35, + "premature_terminal_forbidden": true + } + }, + "enforcement_policy": { + "Q03": "rewrite", + "Q04": "rewrite", + "Q05": "rewrite", + "Q09_pre_end": "block", + "rolling_repeat_escalation": "block", + "rolling_exposition_escalation": "block" + } + }, + "1000": { + "enabled": false, + "diagnostic_enabled": false, + "gate_enforced": false, + "thresholds": { + "repetition_score_max": 0.2, + "exposition_ratio_max": 0.52, + "concrete_detail_density_min": 0.04, + "dialogue_plus_action_ratio_min": 0.42, + "late_window_hook_quality_min": 0.85, + "q09_pre_end_max": 0.08 + }, + "windows": { + "early": { + "start": 1, + "end": 10, + "q03_q04_combined_breach_share_max": 0.45 + }, + "mid": { + "start": 30, + "end": 60, + "repetition_breach_rate_max": 0.3, + "exposition_breach_rate_max": 0.3, + "detail_breach_rate_max": 0.35 + }, + "late": { + "start": 80, + "end": 100, + "q09_breach_rate_max": 0.08, + "detail_breach_rate_max": 0.35, + "premature_terminal_forbidden": true + } + }, + "enforcement_policy": { + "Q03": "rewrite", + "Q04": "rewrite", + "Q05": "rewrite", + "Q09_pre_end": "block", + "rolling_repeat_escalation": "block", + "rolling_exposition_escalation": "block" + } + } + } +} diff --git a/configs/content_quality_strategy_bundles.json b/configs/content_quality_strategy_bundles.json new file mode 100644 index 0000000..0652655 --- /dev/null +++ b/configs/content_quality_strategy_bundles.json @@ -0,0 +1,95 @@ +{ + "config_version": "content_quality_strategy_bundles_v1", + "bundles": { + "q03_q04_scene_dialogue_cadence_task_coupling": { + "label": "Scene + Dialogue + Cadence + Task Coupling", + "issue_codes": ["Q03", "Q04"], + "asset_sequence": [ + "scene_blueprint", + "scene_realization_contracts", + "emotion_action_policies", + "voice_profiles", + "response_cadence_profiles", + "chapter_task_coupling" + ], + "validation_sequence": ["compare", "task_linking", "continuity"], + "success_metrics": [ + "early_window_q03_q04_share", + "mid_window_repeat_breach_rate", + "mid_window_exposition_breach_rate", + "avg_repetition_score", + "avg_exposition_ratio", + "dialogue_ratio" + ], + "stop_condition": "upgrade_to_planner_or_pack_contract_if_two_reruns_flat" + }, + "q03_scene_dialogue_cadence": { + "label": "Scene + Dialogue + Cadence", + "issue_codes": ["Q03"], + "asset_sequence": [ + "scene_blueprint", + "scene_realization_contracts", + "emotion_action_policies", + "voice_profiles", + "response_cadence_profiles" + ], + "validation_sequence": ["compare"], + "success_metrics": [ + "avg_repetition_score", + "mid_window_repeat_breach_rate" + ], + "stop_condition": "upgrade_to_task_coupling_if_flat" + }, + "q04_scene_dialogue_cadence": { + "label": "Scene + Dialogue + Cadence", + "issue_codes": ["Q04"], + "asset_sequence": [ + "scene_blueprint", + "scene_realization_contracts", + "emotion_action_policies", + "character_card", + "response_cadence_profiles" + ], + "validation_sequence": ["compare", "continuity"], + "success_metrics": [ + "avg_exposition_ratio", + "mid_window_exposition_breach_rate", + "dialogue_ratio" + ], + "stop_condition": "upgrade_to_task_coupling_if_flat" + }, + "q05_scene_grounding_detail": { + "label": "Scene Grounding + Detail Density", + "issue_codes": ["Q05"], + "asset_sequence": [ + "scene_blueprint", + "sensory_grounding_policies", + "emotion_action_policies", + "character_card" + ], + "validation_sequence": ["compare", "continuity"], + "success_metrics": [ + "scene_detail_density", + "mid_window_detail_breach_rate", + "late_window_detail_breach_rate" + ], + "stop_condition": "upgrade_to_budget_and_task_balance_if_flat" + }, + "q09_continuation_runway": { + "label": "Continuation Runway", + "issue_codes": ["Q09"], + "asset_sequence": [ + "chapter_task", + "arc_plan", + "scene_realization_contracts" + ], + "validation_sequence": ["task_linking", "compare"], + "success_metrics": [ + "late_window_q09_breach_rate", + "q09_incidence_rate", + "late_arc_pass_rate" + ], + "stop_condition": "upgrade_to_planner_contract_if_flat" + } + } +} diff --git a/configs/longform_capability_profiles.json b/configs/longform_capability_profiles.json new file mode 100644 index 0000000..08f26ee --- /dev/null +++ b/configs/longform_capability_profiles.json @@ -0,0 +1,34 @@ +{ + "quick_brief_max_target_chapters": 100, + "structured_longform_bands": ["250", "500", "1000"], + "bands": { + "100": { + "min_characters": 8, + "min_scene_blueprints": 8, + "min_locations": 6, + "min_scene_family_count": 6, + "min_distinct_role_pairs": 6 + }, + "250": { + "min_characters": 12, + "min_scene_blueprints": 12, + "min_locations": 8, + "min_scene_family_count": 8, + "min_distinct_role_pairs": 8 + }, + "500": { + "min_characters": 16, + "min_scene_blueprints": 16, + "min_locations": 12, + "min_scene_family_count": 10, + "min_distinct_role_pairs": 10 + }, + "1000": { + "min_characters": 24, + "min_scene_blueprints": 24, + "min_locations": 16, + "min_scene_family_count": 12, + "min_distinct_role_pairs": 12 + } + } +} diff --git a/configs/quality/content_rubrics.yaml b/configs/quality/content_rubrics.yaml new file mode 100644 index 0000000..bec25a7 --- /dev/null +++ b/configs/quality/content_rubrics.yaml @@ -0,0 +1,36 @@ +config_version: content_rubrics_v1 +rubrics: + default: + rubric_version: content_quality_rubric_v1 + overall_scale: + min: 1 + max: 5 + dimensions: + correctness: + min: 1 + max: 5 + groundedness: + min: 1 + max: 5 + completeness: + min: 1 + max: 5 + task_fit: + min: 1 + max: 5 + readability: + min: 1 + max: 5 + style_consistency: + min: 1 + max: 5 + safety_compliance: + min: 1 + max: 5 + executability: + min: 1 + max: 5 + veto_reason_codes: + - chapter_quality_guard_failed + - grounding_contradiction + - missing_critical_evidence diff --git a/configs/quality/feedback_reasons.yaml b/configs/quality/feedback_reasons.yaml new file mode 100644 index 0000000..87aaa7e --- /dev/null +++ b/configs/quality/feedback_reasons.yaml @@ -0,0 +1,9 @@ +config_version: quality_feedback_reasons_v1 +reason_codes: + - incorrect + - incomplete + - irrelevant + - unsafe + - bad_style + - unsupported_claim + - not_useful diff --git a/configs/quality/grounding_policies.yaml b/configs/quality/grounding_policies.yaml new file mode 100644 index 0000000..a798eaa --- /dev/null +++ b/configs/quality/grounding_policies.yaml @@ -0,0 +1,22 @@ +config_version: grounding_policies_v1 +policies: + reader_continue: + status_mode: active + sentence_split_pattern: "[。!?!?]" + min_supported_token_hits: 2 + weak_unsupported_claim_max: 1 + min_confidence_for_pass: 0.7 + min_confidence_for_weak: 0.15 + failure_reason_codes: + - grounding_missing_support + - grounding_contradiction + publish_candidate: + status_mode: active + sentence_split_pattern: "[。!?!?]" + min_supported_token_hits: 2 + weak_unsupported_claim_max: 0 + min_confidence_for_pass: 0.75 + min_confidence_for_weak: 0.4 + failure_reason_codes: + - grounding_missing_support + - grounding_contradiction diff --git a/configs/quality/review_policies.yaml b/configs/quality/review_policies.yaml new file mode 100644 index 0000000..b19e8c2 --- /dev/null +++ b/configs/quality/review_policies.yaml @@ -0,0 +1,40 @@ +config_version: quality_review_policies_v1 +policies: + - policy_id: qp_reader_continue_v1 + version: v1 + scenario_id: reader_continue + risk_tier: L2 + rule_ids: + - chapter_quality_gate + - runtime_grounding_placeholder + - review_route_on_non_pass + mode: observe + - policy_id: qp_author_generate_v1 + version: v1 + scenario_id: author_generate_chapter + risk_tier: L2 + rule_ids: + - chapter_quality_gate + - content_quality_contract + - runtime_grounding_placeholder + - review_route_on_non_pass + mode: observe + - policy_id: qp_author_manual_edit_v1 + version: v1 + scenario_id: author_manual_edit + risk_tier: L2 + rule_ids: + - chapter_quality_gate + - content_quality_contract + - runtime_grounding_placeholder + - review_route_on_non_pass + mode: observe + - policy_id: qp_publish_candidate_v1 + version: v1 + scenario_id: publish_candidate + risk_tier: L3 + rule_ids: + - content_quality_contract + - runtime_grounding_placeholder + - review_route_on_non_pass + mode: observe diff --git a/configs/quality/risk_tiers.yaml b/configs/quality/risk_tiers.yaml new file mode 100644 index 0000000..9b57aa5 --- /dev/null +++ b/configs/quality/risk_tiers.yaml @@ -0,0 +1,22 @@ +config_version: quality_risk_tiers_v1 +risk_tiers: + - risk_tier: L1 + label: low + description: Low-risk diagnostic or internal-only quality events. + requires_human_review: false + blocks_on_veto_only: true + - risk_tier: L2 + label: medium + description: User-visible content quality checks on standard runtime paths. + requires_human_review: false + blocks_on_veto_only: false + - risk_tier: L3 + label: high + description: High-risk content or release quality checks that may require structured review. + requires_human_review: true + blocks_on_veto_only: false + - risk_tier: L4 + label: critical + description: Critical quality incidents or policy-sensitive flows that must not bypass review. + requires_human_review: true + blocks_on_veto_only: false diff --git a/configs/quality/rules.yaml b/configs/quality/rules.yaml new file mode 100644 index 0000000..644197f --- /dev/null +++ b/configs/quality/rules.yaml @@ -0,0 +1,26 @@ +config_version: quality_rules_v1 +rules: + - rule_id: chapter_quality_gate + rule_type: evaluator + severity: high + blocking: true + config_ref: src.narrativeos.eval.service:evaluate_persisted_chapter + reason_code: chapter_quality_guard_failed + - rule_id: content_quality_contract + rule_type: validator + severity: high + blocking: true + config_ref: configs/content_quality_contracts.json + reason_code: content_quality_contract_failed + - rule_id: runtime_grounding_placeholder + rule_type: grounding + severity: medium + blocking: false + config_ref: phase1_placeholder + reason_code: grounding_not_evaluated + - rule_id: review_route_on_non_pass + rule_type: review_routing + severity: medium + blocking: false + config_ref: configs/quality/review_policies.yaml + reason_code: quality_review_required diff --git a/configs/quality/scenarios.yaml b/configs/quality/scenarios.yaml new file mode 100644 index 0000000..c29d244 --- /dev/null +++ b/configs/quality/scenarios.yaml @@ -0,0 +1,22 @@ +config_version: quality_scenarios_v1 +scenarios: + - scenario_id: reader_continue + surface: reader + description: Reader continue flow quality evaluation. + default_risk_tier: L2 + quality_policy_id: qp_reader_continue_v1 + - scenario_id: author_generate_chapter + surface: author + description: Author generation and regeneration flow quality evaluation. + default_risk_tier: L2 + quality_policy_id: qp_author_generate_v1 + - scenario_id: author_manual_edit + surface: author + description: Manual chapter edits before persistence. + default_risk_tier: L2 + quality_policy_id: qp_author_manual_edit_v1 + - scenario_id: publish_candidate + surface: publish + description: Release / publish preflight quality evaluation. + default_risk_tier: L3 + quality_policy_id: qp_publish_candidate_v1 diff --git a/configs/release_quality_gate.json b/configs/release_quality_gate.json new file mode 100644 index 0000000..9ddfeb7 --- /dev/null +++ b/configs/release_quality_gate.json @@ -0,0 +1,16 @@ +{ + "config_version": "phase_a_quality_gate_v1", + "cross_pack_pass_rate_min": 0.9, + "weakest_pack_limit": 3, + "weakest_pack_pass_rate_min": 0.55, + "commercial_long_route_chapter_budget_min": 50, + "commercial_long_route_weakest_long_route_quality_min": 0.5, + "commercial_long_route_weakest_completion_ratio_min": 0.8, + "commercial_long_route_weakest_mid_arc_drop_max": 0.35, + "weakest_pack_issue_share_max": { + "Q03": 0.35, + "Q04": 0.3, + "Q05": 0.3, + "Q09": 0.2 + } +} diff --git a/content_quality_rubric.md b/content_quality_rubric.md new file mode 100644 index 0000000..18b240f --- /dev/null +++ b/content_quality_rubric.md @@ -0,0 +1,111 @@ +# 文本质量评分卡与 Rubric + +## 评分目标 +- 让自动 evaluator、规则项和人工审核使用同一套语言。 +- 兼容现有 `Q01-Q10` taxonomy。 +- 每一项都可给 1–5 分,并可携带证据、触发原因、是否 veto。 + +## 评分维度 + +| 维度 | 定义 | 1 分 | 3 分 | 5 分 | 现有信号映射 | +| --- | --- | --- | --- | --- | --- | +| 正确性 | 内容是否与状态、角色、世界事实、前文因果一致 | 明显错误或自相矛盾 | 基本正确但有局部模糊 | 无明显错误,因果稳定 | `Q06` `Q07` canon / critics | +| 依据性 / Groundedness | 是否能被可追溯证据支持 | 关键断言无证据或冲突 | 有部分支持,仍有缺口 | 关键断言均有明确支持 | 待新增 `GroundingCheck` | +| 完整性 | 是否完成当前任务要求而非半截输出 | 大量缺失 | 主体完成但细节不足 | 任务闭环完整 | chapter task / recap / choices / ending signals | +| 任务贴合度 | 是否真正回答当前用户或 chapter task 的目标 | 明显跑题 | 基本相关但偏离重点 | 紧贴目标,偏差小 | simulate target / chapter task / intent | +| 结构与可读性 | 是否便于阅读、结构清晰 | 摘要化、堆砌、难读 | 可读但有解释偏重 | 场景化、节奏自然、钩子清晰 | readability / pacing / hook / scene density | +| 风格一致性 | 是否保持 pack /角色 /场景风格连续 | 风格跳脱 | 有轻微失真 | 风格稳定一致 | voice / dialogue / sensory policies | +| 安全合规性 | 是否符合风险级别、审查和策略边界 | 明显越界 | 有可疑点 | 完全符合策略 | `rating_ceiling` `risk_rating` policy guards | +| 可执行性 | 下游是否可直接消费和继续推进 | 无法入库或无法继续 | 可继续但需要人工补洞 | 可入库、可继续、可追溯 | `quality_gate` / continuity contract | + +## 一票否决规则 + +### 直接 veto +- `Q01` engineering leak +- `Q02` meta narration leak +- `Q07` causal discontinuity high severity +- `Q09` premature ending high severity +- groundedness contradiction +- groundedness missing critical evidence +- safety / policy / permission overreach + +### 条件 veto +- `Q03/Q04/Q05` 连续窗口超阈 +- evaluator 总分低于配置阈值 +- 场景为 L3/L4 且缺少人工审核 + +## 分数解释建议 + +### 1 分 +- 明显错误、不可用、需要阻断 + +### 2 分 +- 存在严重问题,不能直接用户可见 + +### 3 分 +- 可读但需要 rewrite 或人工确认 + +### 4 分 +- 质量较稳,可放行但仍可优化 + +### 5 分 +- 明确满足目标,可作为高质量样本 + +## 与现有 Q01-Q10 的映射 + +| Issue Code | 主要影响维度 | 次要影响维度 | +| --- | --- | --- | +| `Q01` | 安全合规性 / 可执行性 | 正确性 | +| `Q02` | 结构与可读性 / 风格一致性 | 任务贴合度 | +| `Q03` | 结构与可读性 | 完整性 | +| `Q04` | 结构与可读性 / 完整性 | 任务贴合度 | +| `Q05` | 完整性 / 结构与可读性 | 风格一致性 | +| `Q06` | 正确性 / 风格一致性 | 依据性 | +| `Q07` | 正确性 / 依据性 | 可执行性 | +| `Q08` | 任务贴合度 / 可执行性 | 结构与可读性 | +| `Q09` | 完整性 / 可执行性 | 结构与可读性 | +| `Q10` | 可执行性 / 产品连续性 | 任务贴合度 | + +## evaluator 输出建议结构 + +```json +{ + "scorecard_version": "content_quality_rubric_v1", + "overall_score": 3.8, + "dimension_scores": { + "correctness": 4, + "groundedness": 2, + "completeness": 4, + "task_fit": 4, + "readability": 3, + "style_consistency": 4, + "safety_compliance": 5, + "executability": 3 + }, + "veto": false, + "veto_reasons": [], + "evidence_refs": [], + "reason_codes": ["Q05", "grounding_missing_support"], + "summary": "..." +} +``` + +## 证据保存要求 +- 每个低分项都要有 `reason_code` +- 每个 veto 都要有 `veto_reason` +- groundedness 必须带 `evidence_refs` +- 规则检查必须带 `rule_id` +- evaluator 必须带 `rubric_version` + +## 人工审核使用方式 +- 人工审核默认看自动评分,再补结构化理由。 +- 不允许只写自由备注而没有维度分数和 issue code。 +- 人工修改后,要能回写: + - 最终分数 + - 是否通过 + - 修改后 verdict + - 是否进入训练样本 + +## 当前结论 +- 现有 NarrativeEval 已覆盖部分维度,但更偏章节写作质量。 +- 下一阶段需要把 groundedness、任务贴合度、可执行性正式提升为一等维度。 diff --git a/db/alembic/versions/20260413_0014_quality_tables.py b/db/alembic/versions/20260413_0014_quality_tables.py new file mode 100644 index 0000000..793723d --- /dev/null +++ b/db/alembic/versions/20260413_0014_quality_tables.py @@ -0,0 +1,100 @@ +"""Canonical quality tables. + +Revision ID: 20260413_0014 +Revises: 20260404_0012 +Create Date: 2026-04-13 15:00:00 +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import List + +from alembic import op +from sqlalchemy import text + + +revision = "20260413_0014" +down_revision = "20260404_0012" +branch_labels = None +depends_on = None + +ROOT_DIR = Path(__file__).resolve().parents[3] +SQL_PATH = ROOT_DIR / "db" / "migrations" / "0014_quality_tables.sql" +SCHEMA_MIGRATIONS_TABLE = "schema_migrations" + + +def _split_sql(sql_text: str) -> List[str]: + statements: List[str] = [] + current: List[str] = [] + for line in sql_text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("--"): + continue + current.append(line) + if stripped.endswith(";"): + statements.append("\n".join(current).strip().rstrip(";")) + current = [] + if current: + statements.append("\n".join(current).strip().rstrip(";")) + return [statement for statement in statements if statement] + + +def upgrade() -> None: + bind = op.get_bind() + bind.exec_driver_sql( + f""" + create table if not exists {SCHEMA_MIGRATIONS_TABLE} ( + version text primary key, + applied_at timestamptz not null + ); + """ + ) + sql_text = SQL_PATH.read_text(encoding="utf-8") + for statement in _split_sql(sql_text): + bind.exec_driver_sql(statement) + bind.execute( + text( + f""" + insert into {SCHEMA_MIGRATIONS_TABLE} (version, applied_at) + values (:version, :applied_at) + on conflict (version) do nothing + """ + ), + {"version": SQL_PATH.stem, "applied_at": datetime.now(timezone.utc).isoformat()}, + ) + + +def downgrade() -> None: + bind = op.get_bind() + for statement in [ + "drop index if exists idx_review_cases_session_status_updated_at", + "drop index if exists idx_review_cases_world_status_updated_at", + "drop index if exists idx_review_cases_trace_updated_at", + "drop index if exists idx_review_cases_status_updated_at", + "drop table if exists review_cases", + "drop index if exists idx_content_quality_scores_session_created_at", + "drop index if exists idx_content_quality_scores_world_created_at", + "drop index if exists idx_content_quality_scores_status_created_at", + "drop index if exists idx_content_quality_scores_trace_created_at", + "drop table if exists content_quality_scores", + "drop index if exists idx_quality_events_session_created_at", + "drop index if exists idx_quality_events_world_created_at", + "drop index if exists idx_quality_events_surface_status_created_at", + "drop index if exists idx_quality_events_trace_created_at", + "drop table if exists quality_events", + "drop index if exists idx_quality_policies_mode_updated_at", + "drop index if exists idx_quality_policies_scenario_risk_updated_at", + "drop table if exists quality_policies", + ]: + bind.exec_driver_sql(statement) + bind.exec_driver_sql( + f""" + create table if not exists {SCHEMA_MIGRATIONS_TABLE} ( + version text primary key, + applied_at timestamptz not null + ); + """ + ) + bind.execute(text(f"delete from {SCHEMA_MIGRATIONS_TABLE} where version = :version"), {"version": SQL_PATH.stem}) diff --git a/db/alembic/versions/20260414_0015_quality_feedback_items.py b/db/alembic/versions/20260414_0015_quality_feedback_items.py new file mode 100644 index 0000000..012fac2 --- /dev/null +++ b/db/alembic/versions/20260414_0015_quality_feedback_items.py @@ -0,0 +1,87 @@ +"""Canonical quality feedback items. + +Revision ID: 20260414_0015 +Revises: 20260413_0014 +Create Date: 2026-04-14 10:00:00 +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import List + +from alembic import op +from sqlalchemy import text + + +revision = "20260414_0015" +down_revision = "20260413_0014" +branch_labels = None +depends_on = None + +ROOT_DIR = Path(__file__).resolve().parents[3] +SQL_PATH = ROOT_DIR / "db" / "migrations" / "0015_quality_feedback_items.sql" +SCHEMA_MIGRATIONS_TABLE = "schema_migrations" + + +def _split_sql(sql_text: str) -> List[str]: + statements: List[str] = [] + current: List[str] = [] + for line in sql_text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("--"): + continue + current.append(line) + if stripped.endswith(";"): + statements.append("\n".join(current).strip().rstrip(";")) + current = [] + if current: + statements.append("\n".join(current).strip().rstrip(";")) + return [statement for statement in statements if statement] + + +def upgrade() -> None: + bind = op.get_bind() + bind.exec_driver_sql( + f""" + create table if not exists {SCHEMA_MIGRATIONS_TABLE} ( + version text primary key, + applied_at timestamptz not null + ); + """ + ) + sql_text = SQL_PATH.read_text(encoding="utf-8") + for statement in _split_sql(sql_text): + bind.exec_driver_sql(statement) + bind.execute( + text( + f""" + insert into {SCHEMA_MIGRATIONS_TABLE} (version, applied_at) + values (:version, :applied_at) + on conflict (version) do nothing + """ + ), + {"version": SQL_PATH.stem, "applied_at": datetime.now(timezone.utc).isoformat()}, + ) + + +def downgrade() -> None: + bind = op.get_bind() + for statement in [ + "drop index if exists idx_quality_feedback_items_type_signal_created_at", + "drop index if exists idx_quality_feedback_items_session_created_at", + "drop index if exists idx_quality_feedback_items_account_created_at", + "drop index if exists idx_quality_feedback_items_trace_created_at", + "drop table if exists quality_feedback_items", + ]: + bind.exec_driver_sql(statement) + bind.exec_driver_sql( + f""" + create table if not exists {SCHEMA_MIGRATIONS_TABLE} ( + version text primary key, + applied_at timestamptz not null + ); + """ + ) + bind.execute(text(f"delete from {SCHEMA_MIGRATIONS_TABLE} where version = :version"), {"version": SQL_PATH.stem}) diff --git a/db/alembic/versions/20260414_0016_grounding_checks.py b/db/alembic/versions/20260414_0016_grounding_checks.py new file mode 100644 index 0000000..59c4e13 --- /dev/null +++ b/db/alembic/versions/20260414_0016_grounding_checks.py @@ -0,0 +1,87 @@ +"""Canonical grounding checks. + +Revision ID: 20260414_0016 +Revises: 20260414_0015 +Create Date: 2026-04-14 11:00:00 +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import List + +from alembic import op +from sqlalchemy import text + + +revision = "20260414_0016" +down_revision = "20260414_0015" +branch_labels = None +depends_on = None + +ROOT_DIR = Path(__file__).resolve().parents[3] +SQL_PATH = ROOT_DIR / "db" / "migrations" / "0016_grounding_checks.sql" +SCHEMA_MIGRATIONS_TABLE = "schema_migrations" + + +def _split_sql(sql_text: str) -> List[str]: + statements: List[str] = [] + current: List[str] = [] + for line in sql_text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("--"): + continue + current.append(line) + if stripped.endswith(";"): + statements.append("\n".join(current).strip().rstrip(";")) + current = [] + if current: + statements.append("\n".join(current).strip().rstrip(";")) + return [statement for statement in statements if statement] + + +def upgrade() -> None: + bind = op.get_bind() + bind.exec_driver_sql( + f""" + create table if not exists {SCHEMA_MIGRATIONS_TABLE} ( + version text primary key, + applied_at timestamptz not null + ); + """ + ) + sql_text = SQL_PATH.read_text(encoding="utf-8") + for statement in _split_sql(sql_text): + bind.exec_driver_sql(statement) + bind.execute( + text( + f""" + insert into {SCHEMA_MIGRATIONS_TABLE} (version, applied_at) + values (:version, :applied_at) + on conflict (version) do nothing + """ + ), + {"version": SQL_PATH.stem, "applied_at": datetime.now(timezone.utc).isoformat()}, + ) + + +def downgrade() -> None: + bind = op.get_bind() + for statement in [ + "drop index if exists idx_grounding_checks_session_created_at", + "drop index if exists idx_grounding_checks_world_created_at", + "drop index if exists idx_grounding_checks_status_created_at", + "drop index if exists idx_grounding_checks_trace_created_at", + "drop table if exists grounding_checks", + ]: + bind.exec_driver_sql(statement) + bind.exec_driver_sql( + f""" + create table if not exists {SCHEMA_MIGRATIONS_TABLE} ( + version text primary key, + applied_at timestamptz not null + ); + """ + ) + bind.execute(text(f"delete from {SCHEMA_MIGRATIONS_TABLE} where version = :version"), {"version": SQL_PATH.stem}) diff --git a/db/migrations/0014_quality_tables.sql b/db/migrations/0014_quality_tables.sql new file mode 100644 index 0000000..972027c --- /dev/null +++ b/db/migrations/0014_quality_tables.sql @@ -0,0 +1,78 @@ +create table if not exists quality_policies ( + policy_id text primary key, + version text not null, + scenario_id text not null, + risk_tier text not null, + mode text not null, + rule_ids_json jsonb not null, + policy_payload_json jsonb not null, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_quality_policies_scenario_risk_updated_at on quality_policies(scenario_id, risk_tier, updated_at); +create index if not exists idx_quality_policies_mode_updated_at on quality_policies(mode, updated_at); + +create table if not exists quality_events ( + event_id text primary key, + trace_id text not null, + event_type text not null, + source_surface text not null, + status text, + world_version_id text, + session_id text, + source_ref_json jsonb not null, + payload_json jsonb not null, + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_quality_events_trace_created_at on quality_events(trace_id, created_at); +create index if not exists idx_quality_events_surface_status_created_at on quality_events(source_surface, status, created_at); +create index if not exists idx_quality_events_world_created_at on quality_events(world_version_id, created_at); +create index if not exists idx_quality_events_session_created_at on quality_events(session_id, created_at); + +create table if not exists content_quality_scores ( + score_id text primary key, + trace_id text, + source_surface text not null, + status text, + world_version_id text, + session_id text, + chapter_id text, + rubric_version text not null, + overall_score numeric not null default 0, + veto boolean not null default false, + dimension_scores_json jsonb not null, + reason_codes_json jsonb not null, + evidence_refs_json jsonb not null, + score_payload_json jsonb not null, + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_content_quality_scores_trace_created_at on content_quality_scores(trace_id, created_at); +create index if not exists idx_content_quality_scores_status_created_at on content_quality_scores(status, created_at); +create index if not exists idx_content_quality_scores_world_created_at on content_quality_scores(world_version_id, created_at); +create index if not exists idx_content_quality_scores_session_created_at on content_quality_scores(session_id, created_at); + +create table if not exists review_cases ( + case_id text primary key, + trace_id text, + case_type text not null, + status text not null, + owner_id text, + source_surface text, + world_version_id text, + session_id text, + score_id text, + source_ref_json jsonb not null, + reason_codes_json jsonb not null, + evidence_refs_json jsonb not null, + case_payload_json jsonb not null, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_review_cases_status_updated_at on review_cases(status, updated_at); +create index if not exists idx_review_cases_trace_updated_at on review_cases(trace_id, updated_at); +create index if not exists idx_review_cases_world_status_updated_at on review_cases(world_version_id, status, updated_at); +create index if not exists idx_review_cases_session_status_updated_at on review_cases(session_id, status, updated_at); diff --git a/db/migrations/0015_quality_feedback_items.sql b/db/migrations/0015_quality_feedback_items.sql new file mode 100644 index 0000000..08c29ec --- /dev/null +++ b/db/migrations/0015_quality_feedback_items.sql @@ -0,0 +1,20 @@ +create table if not exists quality_feedback_items ( + feedback_item_id text primary key, + trace_id text, + source_event_id text, + feedback_type text not null, + signal text not null, + source_surface text not null, + account_id text, + world_version_id text, + session_id text, + chapter_id text, + source_ref_json jsonb not null, + payload_json jsonb not null, + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_quality_feedback_items_trace_created_at on quality_feedback_items(trace_id, created_at); +create index if not exists idx_quality_feedback_items_account_created_at on quality_feedback_items(account_id, created_at); +create index if not exists idx_quality_feedback_items_session_created_at on quality_feedback_items(session_id, created_at); +create index if not exists idx_quality_feedback_items_type_signal_created_at on quality_feedback_items(feedback_type, signal, created_at); diff --git a/db/migrations/0016_grounding_checks.sql b/db/migrations/0016_grounding_checks.sql new file mode 100644 index 0000000..d24ebf6 --- /dev/null +++ b/db/migrations/0016_grounding_checks.sql @@ -0,0 +1,20 @@ +create table if not exists grounding_checks ( + grounding_check_id text primary key, + trace_id text, + status text not null, + confidence numeric not null default 0, + source_surface text not null, + world_version_id text, + session_id text, + chapter_id text, + evidence_refs_json jsonb not null, + unsupported_claims_json jsonb not null, + reason_codes_json jsonb not null, + summary text not null, + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_grounding_checks_trace_created_at on grounding_checks(trace_id, created_at); +create index if not exists idx_grounding_checks_status_created_at on grounding_checks(status, created_at); +create index if not exists idx_grounding_checks_world_created_at on grounding_checks(world_version_id, created_at); +create index if not exists idx_grounding_checks_session_created_at on grounding_checks(session_id, created_at); diff --git a/db/postgres_schema.sql b/db/postgres_schema.sql index 72995f4..134f3f9 100644 --- a/db/postgres_schema.sql +++ b/db/postgres_schema.sql @@ -341,3 +341,124 @@ create index if not exists idx_usage_meters_world_version_created_at on usage_me create index if not exists idx_analytics_events_event_name_occurred_at on analytics_events(event_name, occurred_at); create index if not exists idx_analytics_events_session_occurred_at on analytics_events(session_id, occurred_at); create index if not exists idx_analytics_events_world_version_occurred_at on analytics_events(world_version_id, occurred_at); + +create table if not exists quality_policies ( + policy_id text primary key, + version text not null, + scenario_id text not null, + risk_tier text not null, + mode text not null, + rule_ids_json jsonb not null, + policy_payload_json jsonb not null, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_quality_policies_scenario_risk_updated_at on quality_policies(scenario_id, risk_tier, updated_at); +create index if not exists idx_quality_policies_mode_updated_at on quality_policies(mode, updated_at); + +create table if not exists quality_events ( + event_id text primary key, + trace_id text not null, + event_type text not null, + source_surface text not null, + status text, + world_version_id text, + session_id text, + source_ref_json jsonb not null, + payload_json jsonb not null, + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_quality_events_trace_created_at on quality_events(trace_id, created_at); +create index if not exists idx_quality_events_surface_status_created_at on quality_events(source_surface, status, created_at); +create index if not exists idx_quality_events_world_created_at on quality_events(world_version_id, created_at); +create index if not exists idx_quality_events_session_created_at on quality_events(session_id, created_at); + +create table if not exists content_quality_scores ( + score_id text primary key, + trace_id text, + source_surface text not null, + status text, + world_version_id text, + session_id text, + chapter_id text, + rubric_version text not null, + overall_score numeric not null default 0, + veto boolean not null default false, + dimension_scores_json jsonb not null, + reason_codes_json jsonb not null, + evidence_refs_json jsonb not null, + score_payload_json jsonb not null, + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_content_quality_scores_trace_created_at on content_quality_scores(trace_id, created_at); +create index if not exists idx_content_quality_scores_status_created_at on content_quality_scores(status, created_at); +create index if not exists idx_content_quality_scores_world_created_at on content_quality_scores(world_version_id, created_at); +create index if not exists idx_content_quality_scores_session_created_at on content_quality_scores(session_id, created_at); + +create table if not exists review_cases ( + case_id text primary key, + trace_id text, + case_type text not null, + status text not null, + owner_id text, + source_surface text, + world_version_id text, + session_id text, + score_id text, + source_ref_json jsonb not null, + reason_codes_json jsonb not null, + evidence_refs_json jsonb not null, + case_payload_json jsonb not null, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_review_cases_status_updated_at on review_cases(status, updated_at); +create index if not exists idx_review_cases_trace_updated_at on review_cases(trace_id, updated_at); +create index if not exists idx_review_cases_world_status_updated_at on review_cases(world_version_id, status, updated_at); +create index if not exists idx_review_cases_session_status_updated_at on review_cases(session_id, status, updated_at); + +create table if not exists quality_feedback_items ( + feedback_item_id text primary key, + trace_id text, + source_event_id text, + feedback_type text not null, + signal text not null, + source_surface text not null, + account_id text, + world_version_id text, + session_id text, + chapter_id text, + source_ref_json jsonb not null, + payload_json jsonb not null, + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_quality_feedback_items_trace_created_at on quality_feedback_items(trace_id, created_at); +create index if not exists idx_quality_feedback_items_account_created_at on quality_feedback_items(account_id, created_at); +create index if not exists idx_quality_feedback_items_session_created_at on quality_feedback_items(session_id, created_at); +create index if not exists idx_quality_feedback_items_type_signal_created_at on quality_feedback_items(feedback_type, signal, created_at); + +create table if not exists grounding_checks ( + grounding_check_id text primary key, + trace_id text, + status text not null, + confidence numeric not null default 0, + source_surface text not null, + world_version_id text, + session_id text, + chapter_id text, + evidence_refs_json jsonb not null, + unsupported_claims_json jsonb not null, + reason_codes_json jsonb not null, + summary text not null, + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_grounding_checks_trace_created_at on grounding_checks(trace_id, created_at); +create index if not exists idx_grounding_checks_status_created_at on grounding_checks(status, created_at); +create index if not exists idx_grounding_checks_world_created_at on grounding_checks(world_version_id, created_at); +create index if not exists idx_grounding_checks_session_created_at on grounding_checks(session_id, created_at); diff --git a/docs/08_eval_and_metrics.md b/docs/08_eval_and_metrics.md index 7839871..0c52328 100644 --- a/docs/08_eval_and_metrics.md +++ b/docs/08_eval_and_metrics.md @@ -62,6 +62,10 @@ 当前默认生成链已接入一个通用 remediation pass,目标不是润色某个 pack,而是统一压以下四类问题: - `Q03 repetition` + - 不再只看词项重复,而是联合判断: + - 段落语义近似 + - event / beat coverage gap + - 结构性回环证据 - 对重复段落做 variation pass,优先改写重复 beat 的段落结构 - `Q04 over-explanation` - 当 exposition ratio 过高或正文过短时,补一段更 scene-facing 的 dialogue pressure @@ -73,6 +77,93 @@ 它的目标不是直接替代更深的 planner/writer 重构,而是先提供一个可复用、可 benchmark 的 kernel-level 修复框架。 +## Long-route Q03 / Grounding hardening + +50 章 Reader 路线证明当前内核可以长跑,但也暴露了两个会随章节数放大的问题:固定 refrain / 地点锚点重复,以及 grounding failed 仍可能被质量事件记录为 passed。 + +当前合同: + +- Reader 生成在持久化前执行 long-route quality controls: + - 清理空槽位,例如 `被压回去的 、` + - 对高频 refrain、位置锚点、默认 choice 文案施加 session 级预算 + - 将 choice text history 写入 state metadata,不改公开 API 或 DB schema +- grounding 是质量 gate 的输入: + - `grounding_status=failed` 会把 Reader gate 改为 `block` + - failed grounding 不允许产生 `quality_event.status=passed` + - `weak` grounding 保留为可观测信号,不阻断正常 Reader 长文 +- 中文 grounding tokenizer 使用 3/4 字片段匹配,避免整句被当成单个 token 导致所有长文误判为 unsupported。 +- 只读诊断 CLI: + - `scripts/diagnose_long_route_quality.py --database-url --session-id --markdown-out ` + - 输出 broken slot、stock refrain、重复 choice、grounding status 和 repetition bundle。 + +目标指标: + +- 50 章 Reader-like route 的 broken slot 命中为 `0` +- 单一默认 choice 不再覆盖所有章节 +- stock refrain 在 session 级别被限制在预算内 +- grounding failed 章节不持久化为 passed quality event + +## Cross-genre hard constraint stack + +Lane A 现在把 Reader 生成质量分成两层: + +- **Hard constraints**:确定性、本地校验、不可被类型 profile 关闭。 + - `schema_complete`:章节标题、正文、choice schema 必须完整。 + - `broken_slot`:空槽位和模板碎片不得进入持久化正文。 + - `engineering_leak` / `meta_narration_leak`:工程字段、路线标记、章节策划口吻不得出现在 Reader 可见文本。 + - `grounding_failed`:failed grounding 必须 block,不能写成 passed quality event。 + - `premature_terminal`:未到路线终局窗口的结局触发必须 block。 + - `stock_refrain_budget` / `choice_text_budget`:长路线高频 refrain 和重复 choice 受 deterministic budget 约束。 +- **Genre / length profiles**:只允许调整阈值,不能禁用 universal hard rules。 + - 类型 profile 覆盖 mystery / romance / fantasy / realist / light_novel。 + - 30 / 50 章 length profile 收紧长路线 repetition 和 choice 预算。 + +执行策略是 `repair_once_then_fail_closed`: + +- 生成后先进入一次通用 repair/control pass。 +- 修复后再跑 hard constraints。 +- 仍失败则返回 `quality_guard_failed`,不保存 step/chapter。 +- `quality_event.payload.hard_constraint_result` 会记录 profile、repair attempts、repair success、failed rule ids。 + +Benchmark markdown 会输出 `Generation Hard Constraint Summary`: + +- hard fail count / rate +- repair attempts / repair success rate +- violation mix by rule id + +这套约束借鉴的是 LLM 推理系统常用的 schema validation、rejection/repair loop、eval gate 和 policy taxonomy;不假设模型供应商训练已经覆盖 NarrativeOS 的世界观、长线连载、Reader choice 或商业化质量要求。 + +## 200 章诊断合同 + +当前 `content_quality_contracts` 已把 `200` 章建模为 diagnostic profile,而不是 merge/publish blocker。 + +- `band = 200` +- `diagnostic_enabled = true` +- `gate_enforced = false` +- 重点观察窗口: + - `early = 1-20` + - `mid = 60-140` + - `late = 160-200` + +该档位会输出: + +- `early_window_q03_q04_share` +- `mid_window_repeat_breach_rate` +- `mid_window_exposition_breach_rate` +- `mid_window_detail_breach_rate` +- `late_window_q09_breach_rate` +- `late_window_detail_breach_rate` +- `contract_issue_surface_counts / rates` + +目标是把 `200` 章作为通用诊断 rail,帮助我们看清强交互中后段的重复、过解释、细节发虚和节奏塌陷,而不是直接作为发布门。 + +这也意味着: + +- `Q05` 不再只在 contract summary 里可见,而要同步进入 benchmark issue surface +- weakest-pack 诊断会额外输出 `window_breach_attribution` + - 例如 `mid:Q05` 或 `late:Q09` + - 并直接映射到 `writer / planner / asset / policy` + ## Cross-pack Benchmark 报表 当前 benchmark 不再只输出 `pass_rate`,而会为每个 pack 提供可诊断报表。 @@ -87,6 +178,8 @@ - `weakest_packs` - `top_failing_packs` - `weakest_pack_diagnostics` +- `strategy_validation_summary` +- `strategy_bundle_batch_validation`(仅当显式开启 batch validate) - `delta_summary` - `long_route_summary`(仅 long-route 模式) @@ -205,6 +298,110 @@ 4. strongest / weakest 榜单相对上次怎么变化 5. 优先应该改哪一层 +### `strategy_bundle_batch_validation` + +当 benchmark 显式开启 `validate_strategy_bundle` 时,顶层会额外输出这一层执行证据。 + +它的目标不是回答“推荐了哪个 bundle”,而是回答“这个 bundle 在 weakest packs 上真实跑起来以后到底有没有用”。 + +至少包含: + +- `strategy_bundle_id / strategy_bundle_label` +- `batch_execution_mode` +- `weakest_source_world_ids` +- `compatible_world_ids` +- `skipped_worlds` +- `validated_world_count` +- `validated_worlds` +- `aggregated_step_receipts` +- `aggregated_result_attribution` +- `effectiveness_rate` +- `decision` +- `decision_reason` +- `adaptation_targets` + +其中: + +- `validated_worlds[*]` + - 至少提供 `world_id / campaign_id / window_label / issue_codes` + - 以及 `step_level_apply_receipt / step_receipt_summary / result_attribution / stop_decision` +- `effectiveness_rate` + - 只按实际被验证的 compatible weakest packs 计算 + - 当前定义为 `overall_status == improved` 或 `ready_for_validation == true` 的 pack 占比 +- `decision` + - 当前只允许 `continue / adapt / retire` + - 用来回答该 bundle 是继续沿用、需要调整,还是应该淘汰 +- `adaptation_targets` + - 当前从 regression 最集中的 metrics 与 noop/skipped 最集中的 asset steps 两类信号里生成 + +这一层现在还会被三个消费面直接使用: + +- benchmark markdown + - 在 `Weakest Pack Polish Program` 之后追加 `Strategy Bundle Batch Validation` +- release evidence / release workspace + - 进入 `release_evidence_bundle.strategy_bundle_batch_validation` + - 同时生成 `...strategy_bundle_batch_validation_summary` +- 进入 `...strategy_bundle_batch_validation_history / ...trend / ...history_summary` +- Ops 现有跨包区块 + - 直接显示 bundle 是否跑过、validated worlds、有 效率、`continue / adapt / retire` 结论,以及 adaptation targets + +注意: + +- 该字段当前是 `evidence-only` +- 它不会改变 publish blockers +- 也不会改变 `release_evidence_bundle.combined_signoff.ready` + +### `strategy_bundle_batch_validation_history` + +当同一个 bundle 被多次显式 batch validate 运行后,系统会保存最近几轮的历史。 + +顶层固定包含: + +- `available` +- `strategy_bundle_id` +- `entry_count` +- `entries` + +每条 `entries[*]` 至少包含: + +- `generated_at` +- `benchmark_mode` +- `validated_world_count` +- `effectiveness_rate` +- `decision` +- `decision_reason` +- `compatible_world_ids` +- `top_adaptation_targets` + +### `strategy_bundle_batch_validation_trend` + +这是对最近几轮 batch validation history 的规则化趋势判断。 + +固定包含: + +- `recent_run_count` +- `latest_decision` +- `latest_effectiveness_rate` +- `previous_effectiveness_rate` +- `delta_effectiveness_rate` +- `trend_status` +- `trend_reason` +- `retire_recommended` + +当前 `trend_status` 只允许: + +- `insufficient_history` +- `improving` +- `flat` +- `deteriorating` +- `retire_watch` + +Ops 现有跨包区块会直接显示这层趋势,用来回答: + +1. 这个 bundle 最近几轮是在变好还是变差 +2. 当前是不是已经进入 retire watch +3. 现在更适合继续沿用、调整,还是准备淘汰 + ### `long_route_summary` 当 benchmark 以 long-route 模式运行时,顶层会额外输出: @@ -217,6 +414,7 @@ - `packs_reaching_target` - `premature_ending_packs` - `stop_reason_counts` +- `q03_q09_calibration` 它的用途是回答: @@ -225,6 +423,58 @@ 3. 重复和解释句是否在长线里持续升高 4. 失败主要是过早收束,还是根本无合法路线 +### Targeted weakest-three compare + +当前可通过 `scripts/run_targeted_longform100_compare.py` 对 weakest three packs 做 focused compare: + +1. 跑 weakest-three baseline `longform_100` +2. 用真实 Reader 会话补 continuation 样本 +3. rerun weakest-three `longform_100` +4. 输出 before / after / delta artifacts + +在做 weakest-pack 的 `Q03` remediation 时,优先修改运行时真正消费的 top-level pack 资产: + +- `voice_profiles` +- `response_cadence_profiles` +- `pressure_response_styles` +- `emotion_action_policies` +- `sensory_grounding_policies` +- `scene_realization_contracts` +- `scene_blueprints` + +不要只改 `narrative_style_pack`。 +当前 runtime 会先从上述 top-level 资产重建 style pack;如果这些字段仍是默认模板,benchmark 会继续回落到通用 opening / detail / beat realization,导致 `semantic_paragraph_similarity_score` 和 `event_coverage_gap_score` 一起升高。 + +当 weakest pack 的 `Q03` 主要来自 chapter 内对同一 continuation motif 的反复拉回时,优先检查三层: + +- `runtime_event_atoms[*].metadata.continuation_blueprints` + - 关键 base event 应优先走事件自带 continuation 分支,而不是完全依赖通用 continuation title/summary +- `simulate_scene_beats()` + - `progression_target` 之后的 pivot / aftermath / echo 应使用独立 beat projection,而不是把同一个 event id 在同一章里重复记两次 +- `coverage_context` + - `selected_event_ids` 应按 event 去重 + - `scene_beats` 仍保留全部 beat,用于 `beat_coverage_gap_score` + +这样才能把“重复 beat”与“重复 event”拆开,不让 `Q03` 因 chapter 结构性回声被误放大。 + +### Weakest-set rebaseline + +当一轮 full `longform_100` confirmation 跑完以后,应立刻重排 weakest set,而不是继续追旧 weakest-three。 + +当前 closeout 后的 weakest set 已切换为: + +- `synthetic_min_pack` +- `jade_court_romance` +- 相对观察包:`jade_court_exam` + +进入下一轮 remediation 时,优先检查: + +- 是否缺少 runtime continuation blueprints +- 是否仍大量回落到 generic continuation titles +- `scene_detail_density / dialogue_ratio / voice_separation_score` 是否同时偏弱 + +如果像 `synthetic_min_pack` 一样在 targeted compare 的补样阶段出现 `continue_status = no_legal_routes`,要把它当成 continuation supply 问题,而不只是 calibration coverage 问题。 + ### Long-route continuation kernel 当 `min_end_turn` 被拉高到 long-route 级别时,静态 candidate provider 现在会在事件池即将耗尽时补充一层 deterministic continuation candidates: @@ -251,6 +501,585 @@ summary 至少包含: - `Weakest Pack Diagnostics` - `Ranking and Metric Delta` +### Lane A / Phase 1 / Task 1.3 longform_100 quality recovery + +2026-04-24 closeout: + +- baseline artifacts: `artifacts/longform100_stability.json`, `artifacts/longform100_stability_repeat2.json` +- after artifacts: + - `artifacts/longform100_q05_q03_after_run1.json` + - `artifacts/longform100_q05_q03_after_run1.md` + - `artifacts/longform100_q05_q03_after_run2.json` + - `artifacts/longform100_q05_q03_after_run2.md` + +Before recovery, all 6 packs reached 100 chapters, but Phase A stayed blocked by weakest-pack issue share: + +- `jade_court_exam`: Q05 share 0.750 +- `jade_court_romance`: Q05 share 0.778 +- `synthetic_min_pack`: Q03 share 0.429, Q05 share 0.429, Q09 share 0.143 + +After recovery, both independent `longform_100` runs are Phase A green: + +- `phase_a_quality_gate.ok`: `true` +- `cross_pack_pass_rate`: 1.0 +- `longform_gate.pass_rate`: 1.0 +- packs reaching 100 chapters: 6/6 +- weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` +- weakest-pack Q03/Q04/Q05/Q09 issue mix: empty in both runs +- strongest packs: `xianxia_forgotten_vow`, then `urban_mystery_lotus_lane` + +Implementation notes: + +- Q05 recovery is kernel-level: final detail repair now uses beat-linked coverage anchors, compact concrete object/sound/body detail, and post-trim length recovery. +- Q03 recovery is kernel-level: final repetition repair can replace repeated paragraphs with coverage bridges or lexical relief, and synthetic fallback dialogue/action now rotates by chapter seed. +- Q09 recovery is route-level: longform promise runway no longer stops opening continuation promises immediately at `min_end_turn` when a series target still has runway before the final 4%. +- Targeted synthetic pack edits are structured runtime assets: continuation blueprint variety, cadence variation, pressure response variation, and repeat-detail anchors. +- No Phase A thresholds or benchmark baselines were lowered. + +Residual watch: + +- `tide_archive_memory_debt` still carries non-weakest Q03/Q04 counts in the full run summaries, but it is not a Phase A blocker after Task 1.3. +- Next Lane A pass should decide whether to bring Tide Archive into the weakest-set polish queue or keep focus on Reader-facing evidence capture. + +### Lane A / Phase 1 / 100-to-250 longform clean run + +2026-04-24 follow-up closeout: + +- 100-chapter current-code artifact: + - `artifacts/longform100_no_redundancy_after_current.json` + - `artifacts/longform100_no_redundancy_after_current.md` +- 250-chapter current-code artifact: + - `artifacts/longform250_no_redundancy_after_current.json` + - `artifacts/longform250_no_redundancy_after_current.md` +- 250 baseline defect artifact: + - `artifacts/longform250_initial_diag.json` + +Before recovery, `longform_250` already reached 250 chapters for all 6 packs, but Phase A was blocked: + +- `phase_a_quality_gate.ok`: `false` +- failed checks: `phase_a_q03_weakest_issue_share_exceeded`, `phase_a_q09_weakest_issue_share_exceeded` +- `cross_pack_pass_rate`: 0.994 +- `synthetic_min_pack`: Q03 x4, Q09 x3, Q04 x2 +- `tide_archive_memory_debt`: Q03 x9, Q09 x5, Q04 x2 +- `urban_mystery_lotus_lane`: Q03 x5, Q09 x2, Q04 x1 + +After recovery, the current-code all-pack `longform_100` run is clean: + +- `phase_a_quality_gate.ok`: `true` +- `cross_pack_pass_rate`: 1.0 +- `longform_gate.pass_rate`: 1.0 +- packs reaching 100 chapters: 6/6 +- per-pack `issue_mix`: empty for all 6 packs +- strongest packs: `tide_archive_memory_debt`, `xianxia_forgotten_vow` +- weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` + +The current-code all-pack `longform_250` run is also clean on content-quality evidence: + +- `phase_a_quality_gate.ok`: `true` +- `cross_pack_pass_rate`: 1.0 +- `longform_250_summary.gate_pass_rate`: 1.0 +- packs reaching 250 chapters: 6/6 +- per-pack `issue_mix`: empty for all 6 packs +- strongest packs: `tide_archive_memory_debt`, `xianxia_forgotten_vow` +- weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` + +Implementation notes: + +- Q03 recovery is kernel-level: coverage anchor scoring ignores generic synthetic/meta anchor text, token coverage is considered alongside semantic similarity, and beat coverage can inherit same-event coverage where appropriate. +- Q04/Q03 interaction recovery is kernel-level: final Q04 micro repair now uses chapter/paragraph-aware action-dialogue variation and is followed by a final repetition/coverage pass plus length-floor recovery. +- No Phase A thresholds or benchmark baselines were lowered. +- Follow-up fresh closeout run: + - artifact: `artifacts/longform250_review_closeout_run2.json` + - backlog: `artifacts/longform250_human_review_backlog_run2.md` + - after reviewer artifact: `artifacts/longform250_review_closeout_run2_after_human.json` + - reviewer receipt: `artifacts/longform250_human_review_closeout_after_reviewer_run2.json` + - `longform_250_signoff.ready`: `true` + - `review_sample_coverage_250.closeout_ready`: `true` + - `review_sample_coverage_250.closeout_status`: `closed_with_auto_seed` + - `review_sample_coverage_250.planned_target_count`: 36 + - `review_sample_coverage_250.executed_target_count`: 36 + - reviewer samples written: 36/36 with `source=human_review` + - `review_sample_coverage_250.human_closeout_ready`: `true` + - `longform_250_human_review_closeout.ready`: `true` +- The first closeout pass only produced auto-seeded samples. The after-reviewer artifact records the separate reviewer samples rather than relabeling auto-seeded samples. + +### Lane A / Phase 1 / longform_500 diagnostic entry + +2026-04-24 diagnostic run after 250 closeout: + +- artifact: `artifacts/longform500_diagnostic_after250_closeout.json` +- markdown: `artifacts/longform500_diagnostic_after250_closeout.md` +- summary: `artifacts/longform500_diagnostic_after250_closeout_summary.json` +- review backlog: `artifacts/longform500_review_backlog_after250_closeout.md` + +Result: + +- `phase_a_quality_gate.ok`: `true` +- `cross_pack_pass_rate`: 1.0 +- `longform_500_summary.gate_pass_rate`: 1.0 +- packs reaching 500 chapters: 6/6 +- `longform_500_signoff.ready`: `true` +- `longform_500_human_review_closeout.ready`: `false` +- `longform_500_ending_signoff.ready`: `false` +- strongest packs: `tide_archive_memory_debt`, `xianxia_forgotten_vow` +- weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` + +Residual issue surface: + +- `urban_mystery_lotus_lane`: Q03 x1 +- `tide_archive_memory_debt`: Q04 x1 +- all other packs: empty `issue_mix` + +500 follow-up: + +- Run 500 review sampling / reviewer closeout for the 36 planned targets, including the `460-500` ending window. +- Investigate the two non-weakest residual issue chapters before claiming strict no-redundancy at 500. +- Keep 500 remediation kernel-level; do not pack-tune single chapters. + +2026-04-25 residual recovery + human/ending closeout: + +- focused Urban artifact: `artifacts/urban_q03_focus_after4.json` +- focused Tide artifact: `artifacts/tide_q04_focus_after8.json` +- final all-pack artifact: `artifacts/longform500_after_residual_fix_closeout_fresh_20260425.json` +- final all-pack markdown: `artifacts/longform500_after_residual_fix_closeout_fresh_20260425.md` +- final all-pack closeout DB: `artifacts/longform500_after_residual_fix_closeout_fresh_20260425.db` + +Result: + +- `phase_a_quality_gate.ok`: `true` +- `cross_pack_pass_rate`: 1.0 +- `longform_500_summary.gate_pass_rate`: 1.0 +- packs reaching 500 chapters: 6/6 +- per-pack `issue_mix`: empty for all 6 packs +- per-pack `surface_issue_chapters`: empty for all 6 packs +- `longform_500_signoff.ready`: `true` +- `longform_500_human_review_closeout.ready`: `true` +- `longform_500_ending_signoff.ready`: `true` +- `review_sample_coverage_500.planned_target_count`: 36 +- `review_sample_coverage_500.executed_target_count`: 36 +- `review_sample_coverage_500.auto_seeded_target_count`: 36 +- `review_sample_coverage_500.human_reviewed_target_count`: 36 +- `review_sample_coverage_500.human_closeout_ready`: `true` +- `review_sample_coverage_500.ending_window_human_closeout_ready`: `true` +- strongest packs: `tide_archive_memory_debt`, `xianxia_forgotten_vow` +- weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` + +Residual deltas: + +- `urban_mystery_lotus_lane` Q03: 1 -> 0 +- `tide_archive_memory_debt` Q04: 1 -> 0 +- all-pack Q03/Q04/Q05/Q09 blockers: 2 -> 0 + +Implementation notes: + +- The benchmark now exposes chapter-level `surface_issue_chapters` diagnostics for longform issue surfaces, including issue codes and lint/repetition/detail metrics. +- Q03 recovery remains kernel-level: final repetition repair can replace repeated long-route paragraphs with beat-aware coverage bridges and multi-beat scene coverage. +- Q04 recovery remains kernel-level: final over-explanation repair is followed by dialogue/action pressure and a repetition/lint recheck. +- Longform repetition validation now uses longform chapter context when evaluating 1500+ unit chapters, preventing 500-route chapters from being misclassified under shortform repetition gates. +- The 500 closeout path writes separate `source=human_review` samples with reviewer id `ops_longform500_reviewer_after_residual_fix`; auto/eval samples remain separate. + +2026-04-25 Reader product replay verification: + +- Reader replay DB: `artifacts/reader_storybook_500_20260425.db` +- seed artifact: `artifacts/reader_storybook_500_20260425_seed.json` +- UI verification artifact: `artifacts/reader_storybook_500_20260425_result.json` +- screenshot directory: `artifacts/reader_storybook_500_20260425_screenshots/` +- redundancy audit artifact: `artifacts/reader_storybook_500_20260425_redundancy_audit.json` +- redundancy audit markdown: `artifacts/reader_storybook_500_20260425_redundancy_audit.md` + +Product display result: + +- Quantum frontend: `http://127.0.0.1:3000` +- API backend: `http://127.0.0.1:8000` +- packs reaching 500 in Reader replay: 6/6 +- Storybook sampled chapter checks: 36/36 +- required selectors verified: title, prose, quote, beats, sequence/active trajectory card +- browser console errors: 0 +- image generation: not enabled + +Reader redundancy audit result: + +- reviewer id: `ops_longform500_reader_redundancy_audit_20260425` +- new `source=human_review` samples: 36 +- DB count for this reviewer/source: 36 +- risk breakdown: low 15, medium 12, high 9 +- `reader_perceived_redundancy_closeout_ready`: `false` +- strongest Reader-perceived packs: `urban_mystery_lotus_lane`, `tide_archive_memory_debt` +- weakest Reader-perceived packs: `jade_court_exam`, `jade_court_romance` + +Status distinction: + +- Benchmark 500 closeout remains green and closed. +- Reader UI replay display closeout is green for the 36 sampled targets. +- Reader-perceived redundancy closeout is not closed; the next Lane A work should reduce Q03 sameness in reusable route/dialogue/scene-function generation. + +2026-04-26 Reader-perceived Q03 recovery closeout: + +- fresh all-pack benchmark: `artifacts/longform500_reader_q03_recovery2_20260425.json` +- benchmark markdown: `artifacts/longform500_reader_q03_recovery2_20260425.md` +- Reader replay DB: `artifacts/reader_storybook_500_q03_recovery2_20260425.db` +- seed artifact: `artifacts/reader_storybook_500_q03_recovery2_20260425_seed.json` +- UI verification artifact: `artifacts/reader_storybook_500_q03_recovery2_20260425_result.json` +- screenshot directory: `artifacts/reader_storybook_500_q03_recovery2_20260425_screenshots/` +- redundancy audit artifact: `artifacts/reader_storybook_500_q03_recovery2_20260425_redundancy_audit.json` +- redundancy audit markdown: `artifacts/reader_storybook_500_q03_recovery2_20260425_redundancy_audit.md` + +Benchmark result: + +- `phase_a_quality_gate.ok`: `true` +- `cross_pack_pass_rate`: 1.0 +- `longform_500_summary.gate_pass_rate`: 1.0 +- packs reaching 500 chapters: 6/6 +- per-pack `issue_mix`: empty for all 6 packs +- strongest packs: `tide_archive_memory_debt`, `xianxia_forgotten_vow` +- weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` + +Product display result: + +- Quantum frontend: `http://127.0.0.1:3000` +- API backend: `http://127.0.0.1:8000` +- Storybook sampled chapter checks: 36/36 +- packs reaching 500 in Reader replay: 6/6 +- browser console errors: 0 +- minimum sampled prose length: 2051 +- minimum sampled quote length: 8 +- minimum sampled beat count: 3 +- image generation: not enabled + +Reader redundancy audit result: + +- reviewer id: `ops_longform500_reader_q03_recovery_20260425` +- new `source=human_review` samples: 36 +- risk breakdown: low 36, medium 0, high 0 +- recovery gate: `high<=0`, `medium<=6` +- `reader_q03_recovery_ready`: `true` +- Jade/xianxia high-risk Q03: 0 +- Urban/Tide high-risk Q03: 0 + +Reader redundancy delta: + +- baseline Reader audit: high 9, medium 12 +- first recovery attempt: high 5, medium 15 +- final recovery audit: high 0, medium 0 + +Implementation notes: + +- Scene opening, event-anchor, hook, and fallback dialogue variation now rotate by chapter/event/scene-function/beat context. +- Reader-facing scene-card quote and beat fields now use chapter-aware persisted replay data instead of static event-title fallbacks. +- SQLite Reader replay payload loading uses lean JSON extraction for Story UI endpoints, avoiding full long-route `plan_json` deserialization during 500 replay verification. +- Phase A thresholds and benchmark baselines were unchanged. + +2026-04-26 Storage + Jade voice + Q05 polish closeout: + +- final all-pack benchmark: `artifacts/longform500_storage_voice_q05_final_20260426.json` +- benchmark markdown: `artifacts/longform500_storage_voice_q05_final_20260426.md` +- standard guardrail artifact: `artifacts/phase0_guardrail_storage_voice_q05_final_20260426.json` +- Reader replay DB: `artifacts/reader_storybook_500_storage_voice_q05_20260426.db` +- seed artifact: `artifacts/reader_storybook_500_storage_voice_q05_20260426_seed.json` +- UI verification artifact: `artifacts/reader_storybook_500_storage_voice_q05_20260426_result.json` +- screenshot directory: `artifacts/reader_storybook_500_storage_voice_q05_20260426_screenshots/` +- redundancy audit artifact: `artifacts/reader_storybook_500_storage_voice_q05_20260426_redundancy_audit.json` +- redundancy audit markdown: `artifacts/reader_storybook_500_storage_voice_q05_20260426_redundancy_audit.md` + +Benchmark result: + +- `phase_a_quality_gate.ok`: `true` +- `content_quality_contract_gate.ok`: `true` +- `cross_pack_pass_rate`: 1.0 +- `longform_500_summary.gate_pass_rate`: 1.0 +- packs reaching 500 chapters: 6/6 +- per-pack `issue_mix`: empty for all 6 packs +- strongest packs: `xianxia_forgotten_vow`, `urban_mystery_lotus_lane` +- weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` + +Polish metrics: + +- Q05 detail density: Urban 0.077, Xianxia 0.082, Jade Exam 0.077, Jade Romance 0.077, Synthetic 0.081, Tide 0.076 +- Jade voice separation: 0.623 -> 0.861 for both `jade_court_exam` and `jade_court_romance` +- Standard Phase A guardrail remains green with `phase_a_quality_gate.ok=true`; longform-only Q05 uplift is intentionally scoped away from short-route chapters. + +Replay storage result: + +- fresh 6-pack x 500 Reader replay DB total size, including WAL/SHM: 383.84 MiB +- `plan_json` p95: 132,100 bytes +- `plan_json` max: 143,078 bytes +- chapter rows: 3000 +- lean storage rows: 3000/3000 +- top-level full debug keys: 0 rows with `step_record`, `candidate_batch`, `scored_candidates`, `routes`, or `promise_ledger_snapshot` + +Reader product replay result: + +- Quantum frontend: `http://127.0.0.1:3000` +- API backend: `http://127.0.0.1:8000` +- Storybook sampled chapter checks: 36/36 +- packs reaching 500 in Reader replay: 6/6 +- browser console errors: 0 +- minimum sampled prose length: 2061 +- minimum sampled quote length: 8 +- minimum sampled beat count: 3 +- image generation: not enabled + +Reader redundancy audit result: + +- reviewer id: `ops_longform500_reader_q03_recovery_20260425` +- new `source=human_review` samples: 36 +- risk breakdown: low 34, medium 2, high 0 +- `reader_q03_recovery_ready`: `true` +- `reader_perceived_redundancy_closeout_ready`: `true` + +Implementation notes: + +- `save_step()` now writes lean replay payloads by default; full step traces require `NARRATIVEOS_STORE_FULL_STEP_RECORD=1`. +- `scripts/compact_replay_plan_json.py` provides explicit old-DB compaction and preserves Reader replay fields, choices, review flags, and human review samples. +- Q05 repair now adds beat-linked sensory anchors and then re-runs final Q03/Q04 coverage and exposition guards. +- The 500 run remains expensive: final all-pack acceptance took roughly 104 minutes locally, so 500 should remain an acceptance/nightly gate until lint/repetition caching is added. + +2026-04-27 Longform acceptance runtime hardening: + +- Benchmark artifacts now include `benchmark_runtime_profile` and per-world `runtime_profile` payloads. +- The profile records wall-clock simulation time, summed chapter generation latency, quality-pass total time, lint/evaluation time, route diagnostics, content-quality-contract metrics, slowest worlds, and quality-pass action buckets. +- Repetition signal analysis now uses a process-local safe LRU cache for identical cleaned paragraph sets; callers receive copies, so lint/repetition payload mutation remains isolated. +- CLI supports `--acceptance-profile fast|full|nightly`, `--changed-worldpacks`, `--fast-gate-weakest-limit`, and `--runtime-profile-out`. +- Fast profile selects changed packs plus baseline weakest packs and marks `nightly_full_gate_required=true` whenever the selected set is not all six benchmark packs. +- Release policy: fast gate is suitable for merge triage; full all-pack 500 remains required for release evidence and Reader replay closeout. + +2026-04-27 Lane A / Phase 1 / Task A1.4 fast-gate precheck: + +- Baseline fast-gate artifact: `artifacts/fast_gate_a14_jade_current_weakest_20260427.json` +- Post-title-polish fast-gate artifact: `artifacts/fast_gate_a14_title_function_after_20260427.json` +- Scope: changed packs `jade_court_exam`, `jade_court_romance`; baseline weakest pack `synthetic_min_pack`. +- Both runs reached 500/500 for all 3 selected packs. +- Both runs kept `phase_a_quality_gate.ok=true`, `content_quality_contract_gate.ok=true`, `cross_pack_pass_rate=1.0`, and `longform_500_summary.gate_pass_rate=1.0`. +- Both runs kept `issue_mix=[]` and Q03/Q04/Q05/Q09 counts at 0 for all selected packs. +- Post-title-polish metrics: Jade Exam detail 0.077 / voice 0.861 / long-route quality 0.897; Jade Romance detail 0.077 / voice 0.861 / long-route quality 0.903; Synthetic detail 0.081 / voice 0.933 / long-route quality 0.865. +- Runtime cost remains high even in fast mode: post-title-polish selected-pack gate took about 44.08 minutes, with about 40.59 minutes attributed to quality pass. + +2026-04-27 Lane A / Phase 1 / Task A1.4 full Reader longform closeout: + +- fresh all-pack benchmark: `artifacts/longform500_a14_closeout_20260427.json` +- benchmark markdown: `artifacts/longform500_a14_closeout_20260427.md` +- runtime profile: `artifacts/longform500_a14_closeout_20260427_runtime.json` +- benchmark DB: `artifacts/longform500_a14_closeout_20260427.db` +- Reader replay DB: `artifacts/reader_storybook_500_a14_closeout_20260427.db` +- Reader replay seed: `artifacts/reader_storybook_500_a14_closeout_20260427_seed.json` +- UI verification artifact: `artifacts/reader_storybook_500_a14_closeout_20260427_result.json` +- screenshot directory: `artifacts/reader_storybook_500_a14_closeout_20260427_screenshots/` +- redundancy audit artifact: `artifacts/reader_storybook_500_a14_closeout_20260427_redundancy_audit.json` +- redundancy audit markdown: `artifacts/reader_storybook_500_a14_closeout_20260427_redundancy_audit.md` + +Benchmark result: + +- `phase_a_quality_gate.ok`: `true` +- `content_quality_contract_gate.ok`: `true` +- `cross_pack_pass_rate`: 1.0 +- `longform_500_summary.gate_pass_rate`: 1.0 +- packs reaching 500 chapters: 6/6 +- per-pack `issue_mix`: empty for all 6 packs +- Q03/Q04/Q05/Q09 counts: 0/0/0/0 for every pack +- strongest packs: `xianxia_forgotten_vow`, `urban_mystery_lotus_lane` +- weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` + +Per-pack quality snapshot: + +| Pack | Detail density | Voice separation | Long-route quality | Issue mix | +| --- | ---: | ---: | ---: | --- | +| `urban_mystery_lotus_lane` | 0.077 | 0.933 | 0.888 | empty | +| `xianxia_forgotten_vow` | 0.082 | 0.934 | 0.890 | empty | +| `jade_court_exam` | 0.077 | 0.861 | 0.897 | empty | +| `jade_court_romance` | 0.077 | 0.861 | 0.903 | empty | +| `synthetic_min_pack` | 0.081 | 0.933 | 0.865 | empty | +| `tide_archive_memory_debt` | 0.076 | 0.938 | 0.883 | empty | + +Runtime result: + +- all-pack full benchmark wall-clock profile: about 101.00 minutes +- quality-pass cost: about 93.86 minutes +- slowest worlds by total time: `urban_mystery_lotus_lane` about 21.05 minutes, `tide_archive_memory_debt` about 20.78 minutes, `synthetic_min_pack` about 17.57 minutes +- This confirms F1.1 fast/deep split remains necessary; full all-pack 500 is release evidence, not routine merge triage. + +Reader product replay result: + +- Quantum frontend: `http://127.0.0.1:3000` +- API backend: `http://127.0.0.1:8000` +- Storybook sampled chapter checks: 36/36 +- packs reaching 500 in Reader replay: 6/6 +- browser console errors: 0 +- minimum sampled prose length: 2061 +- minimum sampled quote length: 8 +- minimum sampled beat count: 3 +- screenshots written: 6 pack screenshots +- image generation: not enabled + +Replay storage result: + +- fresh 6-pack x 500 Reader replay DB total size, including WAL/SHM: 384.05 MiB +- chapter rows: 3000 +- `plan_json` average: 115,516.9 bytes +- `plan_json` p95: 132,006 bytes +- `plan_json` max: 143,148 bytes +- top-level full debug payload keys present: 0 rows for `step_record`, `candidate_batch`, `scored_candidates`, `routes`, or `promise_ledger_snapshot` + +Reader redundancy audit result: + +- reviewer id: `ops_longform500_reader_q03_recovery_20260425` +- reviewed targets: 36/36 +- risk breakdown: low 34, medium 2, high 0 +- `reader_q03_recovery_ready`: `true` +- `reader_perceived_redundancy_closeout_ready`: `true` +- baseline comparison artifact: `artifacts/reader_storybook_500_storage_voice_q05_20260426_redundancy_audit.json` +- Jade guard: `jade_court_exam` medium 1 -> 1, high 0 -> 0; `jade_court_romance` medium 1 -> 1, high 0 -> 0 +- Medium-risk backlog remains bounded to chapter 21 in `jade_court_exam` and chapter 21 in `jade_court_romance`; no high-risk Q03 sample exists. + +2026-04-27 Lane A weakest-pack diagnostic for Reader waiting-state PR: + +- benchmark artifact: `artifacts/lane_a_weakest_pack_diagnostic.md` +- benchmark DB: `artifacts/lane_a_weakest_pack_diagnostic.db` +- command required explicit `--database-url sqlite:///artifacts/lane_a_weakest_pack_diagnostic.db` because this local shell had no default `DATABASE_URL`. +- benchmark mode: `long_route`, chapter budget `36`, min end turn override `30`, all 6 benchmark packs covered. +- `phase_a_quality_gate.ok`: `true` +- `content_quality_contract_gate.ok`: `true` +- `cross_pack_pass_rate`: `1.000` +- benchmark delta vs `tests/long_route_benchmark_baseline.json`: `+0.067` +- strongest packs: `xianxia_forgotten_vow`, `urban_mystery_lotus_lane` +- weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` +- Q03/Q04/Q05 issue mix: clean for weakest packs; Q10 is not covered by this benchmark and remains a Reader continuity / UI follow-up signal, not a generated-prose delta. +- weakest dimensions still point to `scene_detail_density`, `dialogue_ratio`, and either `voice_separation_score` or `character_fidelity`; recommended target remains writer / planner / world pack asset. +- metric regressions: `avg_repetition_score` regressed for `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack`, `urban_mystery_lotus_lane`, and `xianxia_forgotten_vow`, even though issue mix stayed clean. +- runtime: total wall about 410s; quality-pass cost about 392s, confirming this diagnostic is too expensive for every frontend-only change. +- `world_template_minimal` is excluded from benchmark registry. Task 1.11 now filters `catalog_role=template` / `public_catalog_visible=false` from public Reader import and showcase surfaces; if a long-running local backend still shows it, restart the API process so the catalog gate is picked up. + +### Lane A / Phase 1 / Task 1.6 500-chapter product readiness contract + +500 chapters is no longer treated as a single successful long run. A world can only be product-marked as 500-ready when four evidence classes are present: + +- structural capability: `claim_safe_band >= 500` and `longform_readiness.status=ready` +- generation hard constraints: latest 500 evidence includes `generation_hard_constraint_summary.chapter_count >= 500` and `hard_fail_count=0` +- Reader replay projection: 500 Reader replay has a windowed/projection evidence summary, not only a full-route payload +- runtime profile: benchmark output carries `benchmark_runtime_profile` so full 500 cost remains visible + +Task 1.6 adds scene-card visible text to generation hard constraints. `scene_card.title / summary / quote / story_beats / visual_details` are now checked for broken slots, meta narration, engineering leaks, and repeated stock phrases. Benchmark markdown now reports `scene_card_visible_text_audit` alongside the existing hard-fail and repair summary. + +Reader replay supports windowed projection via `start_chapter / end_chapter / limit / latest`. The default remains full replay for compatibility, but 500 Storybook/Reader flows should request early, middle, ending, and recent windows instead of requiring the browser to load the full 500-node route. + +2026-04-28 Task 1.6 synthetic 500 smoke: + +- artifact: `artifacts/task_1_6_synthetic_500_cached.md` +- runtime profile: `artifacts/task_1_6_synthetic_500_cached_runtime.json` +- scope: `synthetic_min_pack` only, `longform_500`, fast acceptance profile; this is not all-pack signoff. +- result: 500/500 chapters reached, `hard_fail_count=0`, `repair_success_rate=1.000`, `scene_card_visible_text_audit.violation_count=0`. +- performance after repetition detector cache: total wall `1,041,620ms`, quality pass `975,709ms`. +- previous same-scope profile before detector cache: total wall `1,107,665ms`, quality pass `1,043,149ms`. +- delta: about 6.0% total wall reduction and 6.5% quality-pass reduction. Useful but not sufficient for the product target; all-pack 500 still belongs in nightly/release gate until the repair loop reduces repeated Q03/length lint passes. +- signoff state remains watch: `longform_500_signoff.ready=false` because benchmark scope is incomplete and interactive/replay signoff still needs all-pack evidence. + +### Lane A / Phase 1 / Tasks 1.7-1.11 500-chapter commercial readiness closeout + +500 readiness is judged against the user experience of reading, choosing, and continuing one chapter at a time. Full benchmark wall-clock remains an internal eval cost signal, not a user-side blocker. + +Task 1.7 fresh all-pack evidence: + +- fixed command: + - `.venv311/bin/python -m src.narrativeos.benchmark.runner --worldpack all --database-url sqlite:///artifacts/lane_a_1_7_all_pack_500.db --benchmark-mode longform_500 --max-chapters 500 --min-end-turn-override 500 --markdown-out artifacts/lane_a_1_7_all_pack_500.md --runtime-profile-out artifacts/lane_a_1_7_all_pack_500_runtime.json` +- benchmark DB: `artifacts/lane_a_1_7_all_pack_500.db` +- benchmark markdown: `artifacts/lane_a_1_7_all_pack_500.md` +- runtime profile: `artifacts/lane_a_1_7_all_pack_500_runtime.json` +- required pass criteria: + - 6/6 benchmark packs reach 500 chapters + - persisted chapter hard violations stay at 0 + - `scene_card_visible_text_audit.violation_count=0` + - `grounding_status=failed` cannot be counted as passed quality + - Q03/Q04/Q05/Q09 issue mix has no blocker +- result: + - cross-pack pass rate: 1.000 + - benchmark delta: +0.067 + - packs reaching 500 chapters: 6/6 + - generation hard constraint chapters: 3000 + - hard fail count: 0 + - repair success rate: 1.000 + - scene-card visible text violations: 0 + - longform 500 gate pass rate: 1.000 + - weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` + - strongest packs: `tide_archive_memory_debt`, `xianxia_forgotten_vow` + - residual issue mix: non-blocking Q03 x1 in `tide_archive_memory_debt` and Q03 x1 in `xianxia_forgotten_vow`; weakest-pack issue mix remains clean. +- runtime: + - total wall ms: 5,839,704.159 (~97.33 minutes) + - quality pass ms: 5,363,617.311 (~89.39 minutes) + - slowest worlds: `urban_mystery_lotus_lane`, `tide_archive_memory_debt`, `synthetic_min_pack` +- signoff interpretation: + - hard generation/replay prerequisites passed. + - benchmark-native `longform_500_signoff`, `human_review_closeout`, and `ending_signoff` still report `watch` because the runner does not ingest the separate Task 1.8 human sampling artifact and because `jade_court_exam` / `jade_court_romance` remain `continue_polish`. + - Therefore this is fresh 500 hard-evidence, not full commercial 500-ready badge authorization for every world. + +Task 1.8 human readability sampling: + +- artifact: `artifacts/lane_a_1_8_500_human_sampling.json` +- markdown: `artifacts/lane_a_1_8_500_human_sampling.md` +- source replay DB: `artifacts/reader_storybook_500_a14_closeout_20260427.db` +- fixed sample chapters per pack: 1, 21, 220, 260, 460, 480 +- result: 36/36 reviewed, risk breakdown low 34 / medium 2 / high 0 +- medium backlog: `jade_court_exam` chapter 21 and `jade_court_romance` chapter 21, both Q03; no high-risk sample and no medium concentration in the 460-500 ending window. + +Task 1.9 legacy content quarantine: + +- Legacy replay text may be repaired at read time by `repair_reader_view_for_display`. +- The repair covers reader-visible title, recap, body, relationship hints, scene-card title/summary/quote/beats/visual details, and choices. +- Repaired responses carry `reader_view.display_sanitization` with source `legacy_read_projection`. +- The raw persisted chapter and original quality event remain unchanged, so historical broken content is not reclassified as fresh pass. + +Task 1.10 Reader single-chapter continuation proof: + +- Reader continue must remain a one-chapter-at-a-time path with waiting state, retryable failures, and no persistence of failed quality guard chapters. +- 500 replay verification must use window projection rather than loading the full route into the Story shell. +- Failure messages must keep backend-down, entitlement/credit, permission, and quality-block states distinct. +- Latest-code isolated replay verification: + - result artifact: `artifacts/lane_a_1_10_500_replay_result.json` + - screenshots: `artifacts/lane_a_1_10_500_replay_screenshots/` + - audit artifact: `artifacts/lane_a_1_10_500_replay_redundancy_audit.json` + - audit markdown: `artifacts/lane_a_1_10_500_replay_redundancy_audit.md` + - app/backend ports: `3001/8012`, because the user's default `3000/8000` processes were already running without backend reload. + - result: 36/36 sampled chapters checked, 6/6 worlds reaching 500, console errors 0, minimum prose length 2061, minimum quote length 8, minimum beat count 3. + - separate `3000` smoke on the already-running app showed no console errors and no 500-ready badge; that process should not be used as fresh backend evidence until API is restarted. + +Task 1.11 Catalog 500-ready gate: + +- Public catalog and showcase may expose `claimSafeBand` for transparency, but must not use it as a 500-ready badge. +- The only public 500-ready signal is `productReadyBand === "500"` plus `longform500ProductReady === true`. +- `catalog_role=template` and `public_catalog_visible=false` versions are excluded from public Reader import/showcase surfaces. +- Ops should retain missing-evidence reasons through `longform_500_product_readiness.blockers`. + +### Lane A / Phase 1 / Task 1.13 Jade continue_polish kernel closure + +Task 1.13 closes the Jade `continue_polish` blocker through kernel-level writer/dialogue/scene realization policy, not pack-local prose edits and not threshold changes. + +- Formal artifact set: + - benchmark DB: `artifacts/lane_a_1_13_all_pack_500.db` + - benchmark JSON: `artifacts/lane_a_1_13_all_pack_500.json` + - benchmark markdown: `artifacts/lane_a_1_13_all_pack_500.md` + - runtime profile: `artifacts/lane_a_1_13_all_pack_500_runtime.json` +- command: + - `.venv311/bin/python -m src.narrativeos.benchmark.runner --worldpack all --database-url sqlite:///artifacts/lane_a_1_13_all_pack_500.db --benchmark-mode longform_500 --max-chapters 500 --min-end-turn-override 500 --execute-human-review-closeout-500 --markdown-out artifacts/lane_a_1_13_all_pack_500.md --runtime-profile-out artifacts/lane_a_1_13_all_pack_500_runtime.json` +- result: + - benchmark scope complete: 6/6 packs + - packs reaching 500 chapters: 6/6 + - `longform_500_summary.gate_pass_rate`: 1.000 + - `longform_500_signoff.ready`: `true` + - `longform_500_human_review_closeout.ready`: `true` + - `longform_500_ending_signoff.ready`: `true` + - `weakest_pack_polish_program.status`: `stop_ready` + - `continue_worlds`: `[]` + - hard fail count: 0 across 3000 chapters + - scene-card visible text violations: 0 + - Q03/Q04/Q05/Q09 issue mix: empty for every benchmark world +- Jade effect: + - `jade_court_exam`: dialogue ratio 0.590, issue mix empty, stop-ready. + - `jade_court_romance`: dialogue ratio 0.593, issue mix empty, stop-ready. +- strongest / weakest: + - all worlds reached pass rate 1.000; weakest long-route quality remains `synthetic_min_pack` at 0.873, but it is stop-ready and not in `continue_worlds`. + - strongest by long-route quality is `urban_mystery_lotus_lane` at 0.896 among non-Jade packs, with `jade_court_romance` at 0.900. +- runtime: + - total wall ms: 7,578,248.316 (~126.30 minutes) + - this is acceptable as release evidence, but eval cost remains a follow-up for profiling/cache work. + ## 离线评测数据包建议 每个 world 至少准备: - 20 条合法路径 diff --git a/docs/architecture/current_generation_pipeline.md b/docs/architecture/current_generation_pipeline.md index 5307ac6..d80c760 100644 --- a/docs/architecture/current_generation_pipeline.md +++ b/docs/architecture/current_generation_pipeline.md @@ -61,8 +61,19 @@ Reader Input - 动作线补写 - story card 文案 +当前长篇补写与场景实现还有两条需要明确记住的 shared behavior: + +- `scene_realizer / sensory_grounding / quality_pass` 现在会带着 `chapter_index` 做开场、hook、感官细节与扩写段落的变体选择,避免长线章节每次都掉回同一组开头句和收尾句。 +- runtime event metadata 会携带通用的 `scene_quality_contract`,供感官细节与质量补写读取 `detail_anchor_types / dialogue_pressure` 这类 pack 资产,而不把 pack-specific 逻辑写进 `core/`。 + 这意味着它还没有和 planner / presenter 完整解耦。 +当前长篇章节长度约束也已经明确挂在这里: + +- renderer 应使用 `state.word_budget / chapter_task.target_words` +- 长篇章节默认预算目标为 `2000`,允许范围 `1800-2200` +- 低于最小长度 gate 的章节不能直接出稿,需要在 quality pass 中扩写到目标区间 + ### 3. Presenter 输出 当前 Presenter 在: @@ -88,7 +99,9 @@ Reader Input 主要文件: - `src/narrativeos/web/index.html` -- `src/narrativeos/web/app.js` +- `src/narrativeos/web/dom_shared.js` +- `src/narrativeos/web/shell_dom.js` +- `src/narrativeos/web/reader_dom.js` 当前问题是: diff --git a/docs/content_quality_contracts.md b/docs/content_quality_contracts.md new file mode 100644 index 0000000..65e4b16 --- /dev/null +++ b/docs/content_quality_contracts.md @@ -0,0 +1,259 @@ +# 通用内容质量约束框架 + +`content_quality_contracts` 是 Lane A 的共享质量 contract 配置,用来把 `Q03 / Q04 / Q05 / Q09` 从 benchmark 报表中的诊断标签升级成可执行的全链路硬约束。 + +## 配置源 + +- 配置文件:`configs/content_quality_contracts.json` +- 当前启用 band:`100` +- 预留 band:`250 / 500 / 1000` +- 生成硬约束:`generation_hard_constraints` + +`100` band 当前默认阈值: + +- `repetition_score_max = 0.20` +- `exposition_ratio_max = 0.52` +- `concrete_detail_density_min = 0.04` +- `dialogue_plus_action_ratio_min = 0.42` +- `late_window_hook_quality_min = 0.85` +- `q09_pre_end_max = 0.08` + +窗口规则: + +- `early (1-10)`:`Q03/Q04` 合计 breach share 不得高于 `0.45` +- `mid (30-60)`:`repetition` / `exposition` breach rate 各不得高于 `0.30` +- `late (80-100)`:`Q09` breach rate 不得高于 `0.08`,且不得出现 premature terminal + +## 生成硬约束 + +`generation_hard_constraints` 定义跨类型文章共享的不可破坏规则。它不依赖 LLM 供应商训练结果,而是在 NarrativeOS 生成后、本地持久化前执行。 + +Universal rules 不能被类型 profile 关闭: + +- `schema_complete` +- `broken_slot` +- `engineering_leak` +- `meta_narration_leak` +- `grounding_failed` +- `premature_terminal` +- `stock_refrain_budget` +- `choice_text_budget` + +类型 profile 只允许调整阈值,例如 mystery 可以更严格地限制 stock refrain;length profile 会在 30/50 章路线收紧重复与 choice 预算。 + +执行策略: + +- prompt/spec 会携带 compact hard constraint contract。 +- 生成后先做一次 repair/control pass。 +- 修复后执行确定性 hard constraint validation。 +- 仍失败则 `quality_gate.enforced_decision = block`,Reader step 不持久化。 +- 质量事件写入 `payload.hard_constraint_result`,用于 Ops / benchmark 统计。 + +## 资产层 contract + +对 `benchmark_enabled && total_chapter_target >= 100` 的 published pack,以下字段必须存在: + +- `scene_blueprint.quality_contract` + - `variation_axes` + - `detail_anchor_types` + - `dialogue_pressure` + - `continuation_obligation` +- `chapter_task.quality_contract` + - `delayed_payoff_window` + - `continuation_pressure_required` + - `max_exposition_ratio` + - `min_dialogue_action_ratio` + - `min_detail_density` +- 顶层质量资产: + - `voice_profiles` + - `sensory_grounding_policies` + - `dialogue_realism_policy` + - `scene_realization_contracts` + +低于 `100` 章的 pack 继续走兼容路径,不强制要求上述 schema。 + +## 章节级 gate + +共享入口仍然是 `evaluate_persisted_chapter -> build_chapter_quality_gate`,但现在会额外接收: + +- `chapter_index` +- `target_chapters` +- `story_phase` +- `scene_quality_contract` +- `chapter_task_quality_contract` +- `rolling_quality_window` +- `enforcement_scope` + +新增 contract checks: + +- `repetition_score_cap` +- `exposition_ratio_cap` +- `detail_density_floor` +- `dialogue_action_floor` +- `continuation_pressure_floor` +- `premature_terminal_forbidden` +- `rolling_window_repeat_breach` +- `rolling_window_exposition_breach` +- `late_window_q09_breach` + +返回 payload 新增: + +- `quality_gate.contract_checks` +- `quality_gate.contract_thresholds` +- `quality_gate.primary_issue_group` +- `quality_gate.primary_asset_target` +- `quality_gate.window_breach_kind` +- `quality_gate.blocking_dimension` +- `quality_gate.enforcement_scope` +- `quality_gate.quality_contract_window` + +## 全链路接入 + +当前接入面: + +- `AuthorWorkService.generate_chapters` +- `AuthorWorkService.edit_chapter` +- `AuthorWorkService._chapter_reports` +- `SessionService.continue_story` +- legacy session API step + +成功写入后,`NarrativeState.metadata.quality_contract_window` 会持久化最近 `5` 章的 contract 观测,用于 rolling breach 升级。 + +失败时会自动生成 `repair_loop_context`,至少包含: + +- `issue_code` +- `asset_type` +- `asset_label` +- `target_label` +- `validation_panel` +- `targeted_chapter_indices` +- `window_breach_kind` + +## Repair Loop + +在 `Author draft detail / work detail / simulate` 中,系统现在会额外输出 `content_quality_repair_workbench`。 + +它基于当前 `content_quality_contract_window_metrics` 生成窗口级 repair campaign: + +- `early 1-10` +- `mid 30-60` +- `late 80-100` + +每个 campaign 至少包含: + +- `window_label / window_range` +- `issue_code / issue_label` +- `breach_kind` +- `strategy_bundle_id / strategy_bundle` +- `targeted_chapter_indices` +- `baseline_issue_count / baseline_worst_decision` +- `primary_asset_type / primary_asset_target` +- `validation_panel` +- `suggested_actions` +- `suggested_field_edits` +- `rerun_scope` + +默认 campaign 选择顺序固定为: + +- 先 `late > mid > early` +- 再 `Q09 > Q04 > Q03` +- 再比较 `failed chapter count / worst decision / average score` + +当窗口里 `Q03 / Q04` 同时存在时,repair loop 默认会优先落到组合策略包: + +- `q03_q04_scene_dialogue_cadence_task_coupling` + +它的目标不是只改一个资产,而是把: + +- `scene_blueprint` +- `scene_realization_contracts` +- `emotion_action_policies` +- `voice_profiles` +- `response_cadence_profiles` +- `chapter_task coupling` + +收成一套统一执行/验证路径。 + +strategy bundle 现在不再只是推荐标签,而会额外返回 execution protocol: + +- `bundle_step_planning` +- `step_level_apply_order` +- `rerun_attribution` +- `stop_condition` + +这让 repair loop 可以继续向真正的 agent 执行协议推进,而不是停在“该改什么”的建议层。 + +现在作者端已经能直接执行 strategy bundle: + +- `POST /v1/author/drafts/{world_version_id}/strategy-bundles/execute` + +输入: + +- `campaign_id?` +- `account_id?` + +如果不传 `campaign_id`,默认执行当前 `content_quality_repair_workbench.default_campaign`。 + +执行时系统会: + +1. 读取 `bundle_step_planning` +2. 按 `step_level_apply_order` 顺序应用字段建议 +3. 生成 `step_level_apply_receipt` +4. 自动触发一次 full rerun +5. 基于 `rerun_attribution` 生成 `result_attribution` +6. 按 `stop_condition` 生成 `stop_decision` + +执行完成后,draft detail / simulate payload 会额外带: + +- `latest_strategy_bundle_execution` +- `strategy_bundle_execution_history` + +`latest_strategy_bundle_execution` 至少包含: + +- `campaign_id` +- `strategy_bundle_id / strategy_bundle_label` +- `bundle_step_planning` +- `step_level_apply_order` +- `step_level_apply_receipt` +- `applied_step_count / applied_edit_count` +- `rerun_attribution` +- `result_attribution` +- `stop_condition` +- `stop_decision` +- `repair_loop_outcome` + +其中: + +- `result_attribution` 会输出 `metric_receipt / improved_metrics / regressed_metrics / flat_metrics / overall_status / primary_signal / candidate_contributors` +- `stop_decision` 只会返回 `stop / continue / escalate` 三种决策,用来决定当前 bundle 是结束、再跑一轮,还是升级到更高层的策略 + +修稿后,`latest_repair_loop_outcome` 会按窗口输出 before/after: + +- `baseline_window_issue_count / current_window_issue_count` +- `baseline_window_worst_decision / current_window_worst_decision` +- `resolved_window_chapters / remaining_window_chapters` +- `ready_for_validation` + +## Benchmark / Release + +每个 world 的 benchmark 输出新增: + +- `content_quality_contract_coverage` +- `content_quality_contract_window_metrics` + +其中窗口指标固定包含: + +- `early_window_q03_q04_share` +- `mid_window_repeat_breach_rate` +- `mid_window_exposition_breach_rate` +- `late_window_q09_breach_rate` +- `contract_failed_chapters` + +顶层新增 `content_quality_contract_gate`,作为 `phase_a_quality_gate` 之外的独立 blocker。 + +它会在以下任一情况阻断发布: + +- 100 章 benchmark-enabled pack 缺少资产层 quality contract coverage +- early / mid / late 窗口 breach 超过 `content_quality_contracts.json` 阈值 + +Release checklist / workspace 已接入这个 blocker,Ops 可在 `publish_blockers.content_quality_contract_gate` 中查看失败明细。 diff --git a/docs/longform500_quality_improvement_dossier_2026-04-25.md b/docs/longform500_quality_improvement_dossier_2026-04-25.md new file mode 100644 index 0000000..1e5807d --- /dev/null +++ b/docs/longform500_quality_improvement_dossier_2026-04-25.md @@ -0,0 +1,632 @@ +# Longform 500 Quality Improvement Dossier - 2026-04-25 + +## Scope + +This dossier summarizes the current 500-chapter generation result, remaining product-quality risks, and the next improvement plan. + +Image generation remains out of scope. + +Primary evidence: + +- Final all-pack artifact: `artifacts/longform500_after_residual_fix_closeout_fresh_20260425.json` +- Final markdown report: `artifacts/longform500_after_residual_fix_closeout_fresh_20260425.md` +- Closeout DB: `artifacts/longform500_after_residual_fix_closeout_fresh_20260425.db` +- Focused Urban Q03 recovery: `artifacts/urban_q03_focus_after4.json` +- Focused Tide Q04 recovery: `artifacts/tide_q04_focus_after8.json` +- Reader replay DB: `artifacts/reader_storybook_500_20260425.db` +- Reader replay seed: `artifacts/reader_storybook_500_20260425_seed.json` +- Reader UI verification: `artifacts/reader_storybook_500_20260425_result.json` +- Reader screenshots: `artifacts/reader_storybook_500_20260425_screenshots/` +- Reader redundancy audit: `artifacts/reader_storybook_500_20260425_redundancy_audit.json` + +## Quantity Result + +The 500-chapter benchmark is currently stable at the automated benchmark level. + +- Benchmark mode: `longform_500` +- Target chapters per pack: 500 +- Packs tested: 6 +- Packs reaching 500 chapters: 6/6 +- `longform_500_summary.gate_pass_rate`: 1.0 +- `cross_pack_pass_rate`: 1.0 +- `phase_a_quality_gate.ok`: `true` +- Failed worlds: none + +Per-pack chapter survival: + +| Pack | Reached chapters | Gate | +| --- | ---: | --- | +| `urban_mystery_lotus_lane` | 500 | pass | +| `xianxia_forgotten_vow` | 500 | pass | +| `jade_court_exam` | 500 | pass | +| `jade_court_romance` | 500 | pass | +| `synthetic_min_pack` | 500 | pass | +| `tide_archive_memory_debt` | 500 | pass | + +## Quality Result + +The blocking 500-chapter residues were cleared. + +- Before residual recovery: + - `urban_mystery_lotus_lane`: Q03 x1 + - `tide_archive_memory_debt`: Q04 x1 +- After residual recovery: + - all-pack `issue_mix`: empty for all 6 packs + - all-pack `surface_issue_chapters`: empty for all 6 packs + - Q03/Q04/Q05/Q09 blockers: 0 + +Longform continuity metrics: + +- `series_boundary_survival`: 1.0 +- `series_memory_snapshot_integrity`: 1.0 +- `memory_recall_coverage`: 1.0 +- `late_series_pass_rate`: 1.0 +- `series_ending_control_score`: 1.0 +- `replan_stability_score`: 0.694 + +Review and signoff state: + +- `review_sample_coverage_500.planned_target_count`: 36 +- `review_sample_coverage_500.executed_target_count`: 36 +- `review_sample_coverage_500.auto_seeded_target_count`: 36 +- `review_sample_coverage_500.human_reviewed_target_count`: 36 +- `review_sample_coverage_500.human_closeout_ready`: `true` +- `review_sample_coverage_500.ending_window_human_closeout_ready`: `true` +- `longform_500_signoff.ready`: `true` +- `longform_500_human_review_closeout.ready`: `true` +- `longform_500_ending_signoff.ready`: `true` + +Review source separation in the closeout DB: + +- `evaluation_report_auto | narrative_eval_auto`: 36 records +- `human_review | ops_longform500_reviewer_after_residual_fix`: 36 records + +## Reader Product Replay Verification + +2026-04-25 product replay verification used the real Quantum frontend on `http://127.0.0.1:3000` and the API on `http://127.0.0.1:8000`, pointed at the already-seeded Reader replay DB. No image generation was enabled. + +- Reader replay sessions reaching 500 chapters: 6/6 +- Sampled chapters rendered in Storybook: 36/36 +- Sample windows per pack: chapters 1, 21, 220, 260, 460, 480 +- Required UI selectors verified: `#reader-v2-storybook-title`, `#reader-v2-storybook-prose`, `#reader-v2-storybook-quote`, `#reader-v2-storybook-beats`, `#reader-v2-storybook-sequence` +- All sampled chapters had title, prose length > 400, non-placeholder quote, at least one beat, and active trajectory-card switching. +- Browser console errors: 0 + +The Reader UI closeout is therefore display-green for the sampled 500 replay targets. The verification also exposed a UI robustness/performance lesson: 500-node replay pages need tolerant loading windows and defensive partial-payload handling, especially for deviation payloads and long session navigation. + +## Reader-Perceived Redundancy Audit + +The Reader replay audit wrote 36 separate `source=human_review` samples with reviewer id `ops_longform500_reader_redundancy_audit_20260425`. These are distinct from benchmark auto/eval samples and from the previous 500 closeout reviewer samples. + +Overall risk: + +- Low: 15 +- Medium: 12 +- High: 9 +- Reader-perceived redundancy closeout ready: `false` + +Per-pack risk: + +| Pack | Low | Medium | High | +| --- | ---: | ---: | ---: | +| `urban_mystery_lotus_lane` | 5 | 1 | 0 | +| `xianxia_forgotten_vow` | 2 | 3 | 1 | +| `jade_court_exam` | 1 | 0 | 5 | +| `jade_court_romance` | 0 | 3 | 3 | +| `synthetic_min_pack` | 2 | 4 | 0 | +| `tide_archive_memory_debt` | 5 | 1 | 0 | + +Interpretation: + +- Benchmark Phase A and automated Q03 remain green, but human-perceived sameness is still present. +- The strongest Reader-perceived packs are `urban_mystery_lotus_lane` and `tide_archive_memory_debt`. +- The weakest Reader-perceived packs are `jade_court_exam` and `jade_court_romance`, with `xianxia_forgotten_vow` carrying one high-risk late-route Q03 sample. +- The next quality task should target recurring chapter-function and dialogue-pressure templates, not single chapter patches. + +## Pack Ranking Snapshot + +Strongest packs: + +| Pack | Diagnostic score | Long-route quality | Detail density | Dialogue ratio | +| --- | ---: | ---: | ---: | ---: | +| `tide_archive_memory_debt` | 0.038 | 0.893 | 0.056 | 0.529 | +| `xianxia_forgotten_vow` | 0.040 | 0.902 | 0.064 | 0.581 | + +Weakest packs: + +| Pack | Diagnostic score | Long-route quality | Detail density | Dialogue ratio | Voice separation | +| --- | ---: | ---: | ---: | ---: | ---: | +| `jade_court_exam` | 0.052 | 0.906 | 0.060 | 0.560 | 0.623 | +| `jade_court_romance` | 0.051 | 0.912 | 0.061 | 0.555 | 0.623 | +| `synthetic_min_pack` | 0.045 | 0.871 | 0.072 | 0.570 | 0.746 | + +Interpretation: + +- The weakest packs are not failing packs; they are diagnostic-priority packs. +- Jade packs are mainly weaker on voice separation and scene-detail density. +- `synthetic_min_pack` is still the lowest long-route-quality pack at 0.871 and has the largest character-fidelity gap at 0.769. +- `tide_archive_memory_debt` is strongest by diagnostic score, but its detail-density metric is still numerically low, so it should not be treated as fully polished prose. + +## Remaining Issues + +### 1. Reader UI display is green, but 500 replay load cost is high + +The product Reader Storybook now proves the real 500 replay can display sampled early, middle, late, and ending chapters across all 6 packs. However, each 500-node session loads a multi-megabyte replay payload in the dev frontend, so verification needs long readiness windows and the product should not assume short-route loading behavior. + +Risk: + +- Large replay payloads can make long routes feel slow even when the page eventually renders correctly. + +Next action: + +- Add long-replay pagination/windowed node loading or a lighter Reader replay projection for product use. + +### 2. Automated no-redundancy is green, but reader-perceived redundancy remains open + +Q03 is clear by the automated repetition and coverage gates, but the 36-target Reader audit found 12 medium and 9 high reader-perceived redundancy risks. + +Risk: + +- Long routes may still feel formulaic if chapter functions, emotional pressure, or scene objects rotate correctly but produce similar reader experience. + +Next action: + +- Prioritize reusable Q03 recovery for Jade Court exam/romance route templates, then xianxia late-route repetition and synthetic medium-risk dialogue pressure. + +### 3. Scene detail density is passing but remains the weakest visible prose dimension + +All packs pass Q05, but detail-density values remain low in absolute terms: + +- `tide_archive_memory_debt`: 0.056 +- `jade_court_exam`: 0.060 +- `jade_court_romance`: 0.061 +- `urban_mystery_lotus_lane`: 0.063 +- `xianxia_forgotten_vow`: 0.064 +- `synthetic_min_pack`: 0.072 + +Risk: + +- Chapters can be structurally valid but still feel thin or under-realized to readers. + +Next action: + +- Improve reusable scene-realization assets and sensory anchors across packs. +- Track Q05 not only as a blocker, but as a polish metric with target uplift. + +### 4. Jade packs remain weak on voice separation + +`jade_court_exam` and `jade_court_romance` both have voice separation score 0.623, the weakest among the 6 packs. + +Risk: + +- Dialogue can pass action/dialogue density while still making characters feel insufficiently distinct. + +Next action: + +- Add pack-structured voice profile assets and a kernel-level dialogue contrast pass. +- Test with targeted Jade samples plus all-pack regression to avoid pack-only tuning. + +### 5. `synthetic_min_pack` remains lowest on long-route quality + +`synthetic_min_pack` reaches 500 and has no issue blockers, but still has: + +- lowest long-route quality: 0.871 +- lowest character fidelity: 0.769 +- small but present mid-arc drop: 0.002 + +Risk: + +- Synthetic route is stable enough for longevity, but still weaker as a stress test for character continuity and route richness. + +Next action: + +- Continue improving generic fallback variation and synthetic structured assets, especially character-state continuity and pressure response variety. + +### 6. Runtime cost is high + +The fresh all-pack 500 run completed, but it took roughly 70+ minutes locally and spent significant CPU in regex-heavy lint/repetition checks. + +Risk: + +- 500/1000 chapter diagnostics may become too slow for routine CI or release gating. + +Next action: + +- Profile and cache repeated lint/repetition calculations. +- Split 500 into nightly/full acceptance and faster per-pack residual reruns. + +## 2026-04-26 Reader-Perceived Q03 Recovery Closeout + +Artifacts: + +- fresh all-pack benchmark: `artifacts/longform500_reader_q03_recovery2_20260425.json` +- benchmark markdown: `artifacts/longform500_reader_q03_recovery2_20260425.md` +- Reader replay DB: `artifacts/reader_storybook_500_q03_recovery2_20260425.db` +- seed artifact: `artifacts/reader_storybook_500_q03_recovery2_20260425_seed.json` +- UI verification artifact: `artifacts/reader_storybook_500_q03_recovery2_20260425_result.json` +- screenshot directory: `artifacts/reader_storybook_500_q03_recovery2_20260425_screenshots/` +- redundancy audit artifact: `artifacts/reader_storybook_500_q03_recovery2_20260425_redundancy_audit.json` +- redundancy audit markdown: `artifacts/reader_storybook_500_q03_recovery2_20260425_redundancy_audit.md` + +Benchmark result: + +- `phase_a_quality_gate.ok`: `true` +- `cross_pack_pass_rate`: 1.0 +- `longform_500_summary.gate_pass_rate`: 1.0 +- packs reaching 500 chapters: 6/6 +- per-pack `issue_mix`: empty for all 6 packs +- strongest packs: `tide_archive_memory_debt`, `xianxia_forgotten_vow` +- weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` + +Reader product replay result: + +- Quantum frontend: `http://127.0.0.1:3000` +- API backend: `http://127.0.0.1:8000` +- Storybook sampled chapter checks: 36/36 +- packs reaching 500 in Reader replay: 6/6 +- browser console errors: 0 +- minimum sampled prose length: 2051 +- minimum sampled quote length: 8 +- minimum sampled beat count: 3 +- image generation: not enabled + +Reader redundancy audit delta: + +- before Reader audit baseline: low 15, medium 12, high 9 +- first recovery attempt: medium 15, high 5 +- final recovery audit: low 36, medium 0, high 0 +- Jade Court high-risk samples: 8 -> 0 +- xianxia high-risk samples: 1 -> 0 +- Urban/Tide high-risk samples: 0 -> 0 +- `reader_q03_recovery_ready`: `true` + +Implementation notes: + +- Kernel scene realization now rotates openings, event anchors, hooks, and dialogue fallbacks by chapter/event/scene-function/beat context. +- Jade Court and xianxia reusable scene/dialogue assets were enriched; no single generated chapter prose was patched. +- Reader scene-card quote/beat rendering now uses chapter-aware persisted replay data so the audit reads product-facing text instead of static event-title fallbacks. +- SQLite Reader replay loading now uses lean JSON extraction for Story payloads, preventing 500 replay UI verification from blocking on full `plan_json` deserialization. +- No Phase A thresholds or benchmark baselines were changed. + +## Improvement Plan + +### P0 - Product replay verification + +Goal: + +- Prove the generated 500-chapter content can be consumed in the Reader UI, not only in benchmark JSON. + +Acceptance: + +- Done on 2026-04-25: 6 Reader replay sessions reached 500 and 36/36 sampled chapters passed Storybook UI checks. + +### P1 - Human-perceived redundancy audit + +Goal: + +- Validate that automated Q03 green correlates with reader-perceived novelty. + +Acceptance: + +- Done on 2026-04-25: 36/36 `source=human_review` audit samples written. +- Closed on 2026-04-26: fresh recovery audit wrote 36/36 new `source=human_review` samples with `high=0`, `medium=0`, and `reader_q03_recovery_ready=true`. + +### P2 - Scene-detail uplift without threshold changes + +Goal: + +- Raise detail-density polish while keeping Q03/Q04 green. + +Acceptance: + +- Detail density improves on weakest packs without increasing issue surface. +- All-pack 500 still passes Phase A. +- No new repeated sensory-anchor pattern. + +### P3 - Jade voice separation recovery + +Goal: + +- Improve `jade_court_exam` and `jade_court_romance` voice separation through reusable assets and kernel dialogue contrast, not chapter patching. + +Acceptance: + +- Jade voice separation improves from 0.623. +- Other packs do not regress. +- Q03/Q04 remain clear. + +### P4 - Synthetic long-route richness + +Goal: + +- Improve `synthetic_min_pack` long-route quality and character fidelity while preserving 500 chapter survival. + +Acceptance: + +- `synthetic_min_pack.long_route_quality` improves from 0.871. +- Character fidelity improves from 0.769. +- Route still reaches 500 and issue mix remains empty. + +### P5 - 500 benchmark runtime hardening + +Goal: + +- Make 500 diagnostics cheaper to run repeatedly. + +Acceptance: + +- Add timing breakdown by pack and quality pass stage. +- Cache duplicate lint/repetition calculations where safe. +- Preserve all current quality outputs and gates. + +## Recommended Next Task + +`[Lane A / Phase 1] 500 Reader Voice Separation + Detail Density Polish` + +Background: + +- The 500 benchmark, Reader Storybook display path, and Reader-perceived Q03 recovery are closed. +- Remaining weakest dimensions are Jade voice separation and cross-pack scene detail density polish, not route survival or high-risk repetition. + +Goal: + +- Improve `jade_court_exam` / `jade_court_romance` voice separation and raise Q05 scene-detail density through reusable assets and kernel-level dialogue/detail contrast. + +Non-goals: + +- No image generation. +- No threshold lowering. +- No pack-specific prose patching. + +Acceptance: + +- Fresh all-pack `longform_500` remains `phase_a_quality_gate.ok: true`. +- Fresh Reader replay verification remains 36/36 display-green. +- Reader redundancy audit remains `high=0`, `medium<=6`. +- Jade voice separation improves from 0.623 without creating Q03/Q04/Q05/Q09 blockers. + +## 2026-04-26 Storage + Jade Voice + Q05 Polish Closeout + +Artifacts: + +- final all-pack benchmark: `artifacts/longform500_storage_voice_q05_final_20260426.json` +- final benchmark markdown: `artifacts/longform500_storage_voice_q05_final_20260426.md` +- standard Phase A guardrail: `artifacts/phase0_guardrail_storage_voice_q05_final_20260426.json` +- Reader replay DB: `artifacts/reader_storybook_500_storage_voice_q05_20260426.db` +- Reader replay seed: `artifacts/reader_storybook_500_storage_voice_q05_20260426_seed.json` +- Reader UI verification: `artifacts/reader_storybook_500_storage_voice_q05_20260426_result.json` +- Reader screenshots: `artifacts/reader_storybook_500_storage_voice_q05_20260426_screenshots/` +- redundancy audit: `artifacts/reader_storybook_500_storage_voice_q05_20260426_redundancy_audit.json` + +Benchmark result: + +- `phase_a_quality_gate.ok`: `true` +- `content_quality_contract_gate.ok`: `true` +- `cross_pack_pass_rate`: 1.0 +- `longform_500_summary.gate_pass_rate`: 1.0 +- packs reaching 500 chapters: 6/6 +- per-pack `issue_mix`: empty for all 6 packs +- strongest packs: `xianxia_forgotten_vow`, `urban_mystery_lotus_lane` +- weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack` + +Q05 / voice result: + +| Pack | Detail density | Voice separation | Issue mix | +| --- | ---: | ---: | --- | +| `urban_mystery_lotus_lane` | 0.077 | 0.933 | empty | +| `xianxia_forgotten_vow` | 0.082 | 0.934 | empty | +| `jade_court_exam` | 0.077 | 0.861 | empty | +| `jade_court_romance` | 0.077 | 0.861 | empty | +| `synthetic_min_pack` | 0.081 | 0.933 | empty | +| `tide_archive_memory_debt` | 0.076 | 0.938 | empty | + +Storage result: + +- fresh 6-pack x 500 Reader replay DB total size, including WAL/SHM: 383.84 MiB +- target: <= 1.2 GiB +- chapter rows: 3000 +- `plan_json` average: 115,558.8 bytes +- `plan_json` p95: 132,100 bytes +- `plan_json` max: 143,078 bytes +- storage mode: 3000/3000 `lean_replay` +- top-level full debug payload keys present: 0 for `step_record`, `candidate_batch`, `scored_candidates`, `routes`, and `promise_ledger_snapshot` + +Reader product replay result: + +- Quantum frontend: `http://127.0.0.1:3000` +- API backend: `http://127.0.0.1:8000` +- Storybook sampled chapter checks: 36/36 +- packs reaching 500 in Reader replay: 6/6 +- browser console errors: 0 +- minimum sampled prose length: 2061 +- minimum sampled quote length: 8 +- minimum sampled beat count: 3 +- image generation: not enabled + +Reader redundancy audit result: + +- reviewer id: `ops_longform500_reader_q03_recovery_20260425` +- new `source=human_review` samples: 36 +- risk breakdown: low 34, medium 2, high 0 +- `reader_q03_recovery_ready`: `true` +- `reader_perceived_redundancy_closeout_ready`: `true` +- medium-risk backlog: one `jade_court_exam` sample and one `jade_court_romance` sample + +Implementation notes: + +- Reader chapter persistence now defaults to lean replay payloads and keeps full step records behind `NARRATIVEOS_STORE_FULL_STEP_RECORD=1`. +- Old full `plan_json.step_record` DBs can be compacted explicitly with `scripts/compact_replay_plan_json.py`; application startup does not auto-migrate artifacts. +- Jade Court voice profile enrichment moved `jade_court_exam` and `jade_court_romance` from 0.623 to 0.861 without pack-local chapter patches. +- Longform Q05 repair now adds beat-linked object/sound/body/ambient anchors and then re-runs Q03/Q04 guards. +- A final coverage/Q04 guard was required to clear Urban Q03 and Synthetic Q04 residues after Q05 top-up. + +Remaining risk: + +- The final all-pack `longform_500` run took roughly 104 minutes locally. The product path is green, but 500 diagnostics still need profiling/caching before they can be routine CI. +- Standard short-route detail density remains lower for some packs because aggressive Q05 uplift is scoped to true longform chapters to keep Phase A stable. + +## 2026-04-27 Runtime Hardening Addendum + +Implemented: + +- Benchmark JSON now includes top-level `benchmark_runtime_profile` and each world includes `runtime_profile`. +- Runtime profile covers simulation wall time, summed chapter generation latency, quality-pass total time, lint/evaluation time, route diagnostics, content-quality-contract metrics, and slowest worlds. +- Quality-pass actions are grouped into Q03 repetition, Q04 exposition, Q05 detail, Q09 pacing, length recovery, and other buckets. Per-stage milliseconds are marked as estimates derived from actual quality-pass total time plus action distribution. +- Repetition signal analysis now has a process-local safe LRU cache for identical cleaned paragraph sets. +- CLI can run `--acceptance-profile fast --changed-worldpacks ` to rerun changed packs plus baseline weakest packs, while preserving full/nightly all-pack 500 as release evidence. + +Release-gate policy: + +- Fast gate is a merge-triage tool only. +- Full all-pack `longform_500`, Reader 500 replay verification, and redundancy audit remain required before claiming commercial Beta content closeout. + +## 2026-04-27 A1.4 Reader Longform Title / Function Fast-Gate Check + +Scope: + +- Task: `[Lane A / Phase 1 / Task A1.4] Reader Longform Polish: Title, Function, Jade Medium Redundancy` +- Precondition: use the new fast gate before claiming title/function polish is safe. +- Selected packs: changed Jade packs plus current weakest baseline pack. +- Image generation: not enabled. + +Artifacts: + +- Baseline fast gate before the title polish patch: `artifacts/fast_gate_a14_jade_current_weakest_20260427.json` +- Baseline markdown: `artifacts/fast_gate_a14_jade_current_weakest_20260427.md` +- Baseline runtime profile: `artifacts/fast_gate_a14_jade_current_weakest_20260427_runtime.json` +- Post-title-polish fast gate: `artifacts/fast_gate_a14_title_function_after_20260427.json` +- Post-title-polish markdown: `artifacts/fast_gate_a14_title_function_after_20260427.md` +- Post-title-polish runtime profile: `artifacts/fast_gate_a14_title_function_after_20260427_runtime.json` + +Fast-gate result: + +| Run | Packs | Phase A | Cross-pack | 500 gate | Issue mix | Q03/Q04/Q05/Q09 | +| --- | --- | --- | ---: | ---: | --- | --- | +| baseline | Jade Exam, Jade Romance, Synthetic | pass | 1.0 | 1.0 | empty | 0/0/0/0 | +| post-title-polish | Jade Exam, Jade Romance, Synthetic | pass | 1.0 | 1.0 | empty | 0/0/0/0 | + +Post-title-polish selected-pack metrics: + +| Pack | Reached chapters | Detail density | Voice separation | Long-route quality | Issue mix | +| --- | ---: | ---: | ---: | ---: | --- | +| `jade_court_exam` | 500 | 0.077 | 0.861 | 0.897 | empty | +| `jade_court_romance` | 500 | 0.077 | 0.861 | 0.903 | empty | +| `synthetic_min_pack` | 500 | 0.081 | 0.933 | 0.865 | empty | + +Implementation note: + +- Reader chapter title generation now rotates deterministic, scene-facing Chinese title tails by chapter/event/scene-function context instead of reusing `scene_intent.label`. +- The patch does not change body generation, Phase A thresholds, benchmark baselines, Reader API shape, or image-generation behavior. +- Focused tests confirm title tails rotate across long-route chapter windows and do not leak internal scene-function tokens such as `vow_payment`. + +Remaining A1.4 risk: + +- The fast gate proves no selected-pack Q03/Q04/Q05/Q09 regression, but it is not a replacement for the fresh all-pack 500 + Reader replay UI verification + redundancy audit required to close A1.4. +- Fast gate is still expensive: the post-title-polish selected-pack run took about 44.08 minutes locally, with quality pass accounting for about 40.59 minutes. + +## 2026-04-27 A1.4 Full Reader Longform Closeout + +Scope: + +- Task: `[Lane A / Phase 1 / Task A1.4] Full Reader Longform Closeout` +- Evidence chain: fresh all-pack `longform_500`, fresh all-pack Reader 500 replay seed, product UI Storybook verification on `http://127.0.0.1:3000`, and fresh redundancy audit. +- Image generation: not enabled. +- Phase A thresholds and benchmark baselines: unchanged. + +Artifacts: + +- fresh all-pack benchmark: `artifacts/longform500_a14_closeout_20260427.json` +- benchmark markdown: `artifacts/longform500_a14_closeout_20260427.md` +- runtime profile: `artifacts/longform500_a14_closeout_20260427_runtime.json` +- benchmark DB: `artifacts/longform500_a14_closeout_20260427.db` +- Reader replay DB: `artifacts/reader_storybook_500_a14_closeout_20260427.db` +- Reader replay seed: `artifacts/reader_storybook_500_a14_closeout_20260427_seed.json` +- Reader UI verification: `artifacts/reader_storybook_500_a14_closeout_20260427_result.json` +- Reader screenshots: `artifacts/reader_storybook_500_a14_closeout_20260427_screenshots/` +- redundancy audit: `artifacts/reader_storybook_500_a14_closeout_20260427_redundancy_audit.json` +- redundancy audit markdown: `artifacts/reader_storybook_500_a14_closeout_20260427_redundancy_audit.md` + +Full benchmark result: + +| Metric | Result | +| --- | --- | +| `phase_a_quality_gate.ok` | `true` | +| `content_quality_contract_gate.ok` | `true` | +| `cross_pack_pass_rate` | 1.0 | +| `longform_500_summary.gate_pass_rate` | 1.0 | +| Packs reaching 500 | 6/6 | +| Per-pack `issue_mix` | empty for all packs | +| Q03/Q04/Q05/Q09 blockers | 0/0/0/0 for every pack | + +Per-pack quality snapshot: + +| Pack | Reached chapters | Detail density | Voice separation | Long-route quality | Issue mix | +| --- | ---: | ---: | ---: | ---: | --- | +| `urban_mystery_lotus_lane` | 500 | 0.077 | 0.933 | 0.888 | empty | +| `xianxia_forgotten_vow` | 500 | 0.082 | 0.934 | 0.890 | empty | +| `jade_court_exam` | 500 | 0.077 | 0.861 | 0.897 | empty | +| `jade_court_romance` | 500 | 0.077 | 0.861 | 0.903 | empty | +| `synthetic_min_pack` | 500 | 0.081 | 0.933 | 0.865 | empty | +| `tide_archive_memory_debt` | 500 | 0.076 | 0.938 | 0.883 | empty | + +Strongest / weakest effect: + +- Strongest packs: `xianxia_forgotten_vow`, `urban_mystery_lotus_lane`. +- Weakest packs: `jade_court_exam`, `jade_court_romance`, `synthetic_min_pack`. +- The weakest status is now polish-oriented: Jade remains weaker on voice/detail dimensions than the strongest packs, but it has no Q03/Q04/Q05/Q09 blocker and no high-risk reader redundancy sample. + +Runtime profile: + +- Full all-pack benchmark runtime: about 101.00 minutes. +- Quality pass runtime: about 93.86 minutes. +- Slowest worlds: `urban_mystery_lotus_lane` about 21.05 minutes, `tide_archive_memory_debt` about 20.78 minutes, `synthetic_min_pack` about 17.57 minutes. +- This reinforces the release-gate policy: full all-pack 500 is required for release evidence, while fast gates remain the merge-triage path. + +Reader replay and UI result: + +- Fresh Reader replay sessions reaching 500 chapters: 6/6. +- Product frontend URL: `http://127.0.0.1:3000`. +- API backend URL: `http://127.0.0.1:8000`. +- Storybook target checks: 36/36. +- Browser console errors: 0. +- Required selectors rendered in the product UI: title, prose, quote, beats, sequence/trajectory active card. +- Minimum sampled prose length: 2061. +- Minimum sampled quote length: 8. +- Minimum sampled beat count: 3. +- Screenshots written: 6 pack screenshots. + +Replay storage result: + +- Fresh 6-pack x 500 Reader replay DB total size, including WAL/SHM: 384.05 MiB. +- Chapter rows: 3000. +- `plan_json` average: 115,516.9 bytes. +- `plan_json` p95: 132,006 bytes. +- `plan_json` max: 143,148 bytes. +- Full debug payload keys present at top level: 0 rows for `step_record`, `candidate_batch`, `scored_candidates`, `routes`, or `promise_ledger_snapshot`. + +Reader redundancy audit: + +| Scope | Low | Medium | High | Unknown | +| --- | ---: | ---: | ---: | ---: | +| 2026-04-26 baseline | 34 | 2 | 0 | 0 | +| 2026-04-27 full A1.4 closeout | 34 | 2 | 0 | 0 | + +Jade guard: + +| Pack | Baseline medium/high | Current medium/high | Delta | +| --- | ---: | ---: | --- | +| `jade_court_exam` | 1 / 0 | 1 / 0 | no increase | +| `jade_court_romance` | 1 / 0 | 1 / 0 | no increase | + +Closeout status: + +- `reviewed_count`: 36. +- `reader_q03_recovery_ready`: `true`. +- `reader_perceived_redundancy_closeout_ready`: `true`. +- Overall high-risk reader-perceived Q03 remains 0. +- Overall medium-risk count remains 2, matching the accepted baseline. +- Medium backlog remains bounded to `jade_court_exam` chapter 21 and `jade_court_romance` chapter 21. + +Operational note: + +- During the first resume after seed, port 8000 was occupied by an existing backend. That process was stopped rather than switching to another port, and verification resumed against the same fresh seed DB on the required 3000/8000 ports. diff --git a/docs/product_quality_audit_2026-04-23.md b/docs/product_quality_audit_2026-04-23.md new file mode 100644 index 0000000..6bb1c67 --- /dev/null +++ b/docs/product_quality_audit_2026-04-23.md @@ -0,0 +1,144 @@ +# Product Quality Audit - 2026-04-23 + +Task label: `[Lane C / Phase 3 / Task QA] Subscription/image2 gating and front-back alignment audit` + +## Goal + +Run a narrow product-quality audit for Reader / Author / Ops front-back alignment, current failing surfaces, and whether `gpt-image-2` can be covered by a subscription-plan strategy instead of uncontrolled per-image spend. + +## Validation Summary + +- Backend targeted QA suite: 98 passed, 12 failed. +- Quantum React frontend Vitest: 39 passed. +- Quantum React frontend production build: passed with one bundle-size warning. +- Health and boot probe: `/health`, `/api/v1/health`, `/app`, and `/v1/examples` returned 200 via `TestClient`. +- Benchmark / eval rerun: not run; this audit did not change narrative kernel/prose behavior. + +## Current Issues + +### P1 - Subscription and image2 are not connected + +`IllustrationService` defaults to `NARRATIVEOS_IMAGE_MODEL=gpt-image-2` and `NARRATIVEOS_ILLUSTRATIONS_ENABLED=true`. Session creation and story continuation enqueue `session_cover`, `world_cover`, and `chapter_hero` directly after runtime work. The enqueue gate only checks service enabled state, storage, OpenAI API key, and async jobs. It does not check subscription tier, wallet balance, per-account quota, or an image-specific entitlement rule. + +Effect: + +- A Reader can trigger paid image generation through normal session/chapter flow once global env and provider credentials are enabled. +- Current `Play Pass / Creator Pass / Studio Pass` config meters text continuation and author actions, but not image generation. +- Ops can observe generated-media assets/events, but cannot set a product policy such as "Studio gets N hero images/month" or "Reader uses low-quality thumbnails only." + +Recommended fix: + +- Add `image_credits` or `media_credits` wallet and explicit entitlement matrix rules for `world_cover`, `session_cover`, `chapter_hero`, and on-demand generation. +- Gate automatic image enqueue in `SessionService`/`IllustrationService` through `BillingService`, not by UI. +- Default local/prod rollout should set `NARRATIVEOS_ILLUSTRATIONS_ENABLED=false` until the gate exists. +- Add image generation metering events with model, quality, size, output token estimate/cost estimate, account_id, session_id, and asset_kind. + +### P1 - Frontend dark-matter economics do not match backend metering + +Settings UI says "约10暗物质/章 (~$0.02)". Backend config currently charges `reader_continue_story_credits = 1`, and ink packages start at 500 credits for $0.99. + +Effect: + +- User-facing copy implies 10 credits per chapter. +- Backend consumes 1 `story_credit` per paid continuation. +- This makes price-per-chapter and wallet expectations off by 10x. + +Recommended fix: + +- Move per-chapter copy to backend commerce payload, derived from `configs/monetization_tiers.json`. +- Add a frontend contract test asserting displayed chapter cost equals the backend `metering.reader_continue_story_credits` rule. + +### P1 - Monetization backend tests are stale against Ops auth hardening + +The Ops middleware now requires privileged identity for `/v1/ops/*` reads/writes. Many `tests/test_monetization_m0.py` cases still call Ops mutation/read endpoints without headers. Those calls now return `403 ops_actor_missing`, so subsequent subscription and wallet assertions fail. + +Effect: + +- The product behavior is more secure, but the monetization regression suite no longer proves subscription lifecycle correctness through API paths. +- Several failures are cascading false negatives after unauthorized Ops grants. + +Recommended fix: + +- Update monetization API tests to use `tests/ops_auth_helpers.py`. +- Keep one negative test for unauthenticated Ops mutation; route the rest through reviewer/admin headers. + +### P2 - Auth cookie/account-id mismatch can break Reader API probes + +One observability endpoint test registers/logs in an Ops identity, then creates a Reader session for a different `account_id` using the same `TestClient`. Reader account resolution treats the existing auth token/cookie as authoritative and rejects a mismatched provided account. + +Effect: + +- This is correct from an ownership-policy perspective, but tests and any legacy shell flows that expose a free-form Reader account field while logged in can produce confusing 403s. + +Recommended fix: + +- Tests should isolate clients or use matching account IDs. +- UI should avoid sending arbitrary `account_id` when authenticated; derive account identity from token. + +### P2 - Quality gate expectation drift + +`tests/test_provider_runtime_routing.py::test_reader_runtime_quality_gate_blocks_failed_chapter_persistence` expected `required_text_units >= 1800`; current runtime returned `990`. + +Effect: + +- Either the runtime quality gate no longer receives the longform `min_target_words=1800` contract for this route, or the test assumes a longform route while the current route is short/derived from `target_words * 0.9`. +- This weakens P0 quality signal unless intentional. + +Recommended fix: + +- Confirm whether paid Reader continuation should enforce the 1800 floor. +- If yes, pass chapter budget policy into runtime quality gate consistently. +- If no, update test expectation and report the route as shortform/non-longform. + +### P2 - Frontend fallback can hide backend absence + +The Quantum React client falls back to local demo data when health/network checks fail. This keeps the UI usable, but can mask production API drift unless the backend status banner and CI tests stay strict. + +Recommended fix: + +- Keep `VITE_API_LOCAL=false` for acceptance. +- Add a hard acceptance mode that fails on demo fallback for purchase, subscription, image, and Reader continuation flows. + +## image2 Subscription Plan Assessment + +OpenAI ChatGPT subscriptions and OpenAI API billing are separate systems. A ChatGPT subscription should not be treated as a way to cover API `gpt-image-2` costs. Product subscription plans can still be used internally, but only by gating and budgeting our own API calls through NarrativeOS entitlements/wallets. + +OpenAI official pricing currently lists `gpt-image-2` as API-token priced. Standard `gpt-image-2` image output is $30.00 / 1M image output tokens, and Batch is $15.00 / 1M output tokens. The image guide's example calculator lists GPT Image 2 output estimates at: + +- 1024x1024 low: about $0.006 +- 1024x1024 medium: about $0.053 +- 1024x1024 high: about $0.211 +- 1536x1024 low: about $0.005 +- 1536x1024 medium: about $0.041 +- 1536x1024 high: about $0.165 + +Current repo calls `/v1/images/generations` with `model`, `prompt`, and `size` only. It does not set `quality`, so it leaves quality at provider default/auto behavior. For cost control, use explicit `quality: "low"` for drafts/thumbnails and reserve medium/high for final assets. + +## Recommended Next Task + +`[Lane C / Phase 3 / Task QA.1] Image generation entitlement and cost gate` + +Background: `gpt-image-2` is wired to Reader illustration delivery, but not to subscription/wallet policy. + +Goal: Add central media-generation entitlement rules and make all automatic image enqueue paths honor them. + +Non-goals: Do not change prompt style, worldpack art direction, or generated prose. + +Scope: + +- `configs/monetization_tiers.json` +- `src/narrativeos/services/billing.py` +- `src/narrativeos/services/illustration.py` +- `src/narrativeos/services/sessions.py` +- tests for Reader session, continue, on-demand illustration, and Ops audit. + +Acceptance: + +- Free/unsubscribed accounts do not enqueue paid images automatically. +- Play/Creator/Studio tiers get explicit monthly media quotas. +- Every image job records metering/audit with model, size, quality, asset_kind, and account_id. +- Frontend receives clear image availability/fallback state without hardcoded tier logic. + +Rollback point: + +- Revert the media entitlement rule and the IllustrationService gate; keep `NARRATIVEOS_ILLUSTRATIONS_ENABLED=false` as kill switch. diff --git a/docs/tide_archive_100_chapter_test_pack.md b/docs/tide_archive_100_chapter_test_pack.md new file mode 100644 index 0000000..1100123 --- /dev/null +++ b/docs/tide_archive_100_chapter_test_pack.md @@ -0,0 +1,191 @@ +# 潮汐档案 100章压测 Pack + +`tide_archive_memory_debt` 是一个 `benchmark-enabled` 的 Lane A 压测 world pack,用来验证 NarrativeOS 在 `100章 / 5卷 / 15弧 / 约20万字` 条件下的长线创作续航。 + +## 目标 + +- 题材:近未来海港悬疑 + 情感群像 + 阴谋推进 +- 核心命题:当记忆可以被交易,人靠什么承担承诺 +- 主要压测问题:`Q03 / Q04 / Q05 / Q09`、角色一致性、延迟回收、中后段续航 +- 路线家族:真相优先 / 关系优先 / 生存优先 / 权力优先 +- 结局家族:公开揭露 / 私下保全 / 牺牲封口 / 带罪共存 + +## 角色与结构 + +- 角色数:10 +- 地点数:8 +- scene families:10 +- distinct role pairs:12 +- 长线结构:5 卷、每卷 20 章、每卷 3 弧 + +卷结构固定为: + +- 卷 1 `潮门初裂`:建立世界规则、关系债和第一次错误选择 +- 卷 2 `账本上岸`:调查升级、阵营分裂、首次公开代价 +- 卷 3 `中潮迷航`:中段疲劳带,专门观察重复、解释增多与角色漂移 +- 卷 4 `旧债回潮`:旧债集中结算,路线显著分化 +- 卷 5 `终港对证`:85-95 章保持 continuation pressure,96-100 收束但禁止偷懒终结 + +## Benchmark 用法 + +标准 6 章: + +```bash +source .venv/bin/activate +python -m src.narrativeos.benchmark.runner \ + --worldpack tide_archive_memory_debt \ + --database-url sqlite:///narrativeos_beta.db +``` + +36 / 30 long-route: + +```bash +source .venv/bin/activate +python -m src.narrativeos.benchmark.runner \ + --worldpack tide_archive_memory_debt \ + --baseline-file tests/long_route_benchmark_baseline.json \ + --max-chapters 36 \ + --min-end-turn-override 30 \ + --database-url sqlite:///narrativeos_beta.db +``` + +100 章主压测: + +```bash +source .venv/bin/activate +python -m src.narrativeos.benchmark.runner \ + --worldpack tide_archive_memory_debt \ + --benchmark-mode longform_100 \ + --max-chapters 100 \ + --database-url sqlite:///narrativeos_beta.db +``` + +100 章 interactive: + +```bash +source .venv/bin/activate +python -m src.narrativeos.benchmark.runner \ + --worldpack tide_archive_memory_debt \ + --benchmark-mode longform_100_interactive \ + --max-chapters 100 \ + --database-url sqlite:///narrativeos_beta.db +``` + +## 人工抽检 + +固定窗口: + +- `1-5` +- `18-22` +- `38-42` +- `58-62` +- `78-82` +- `96-100` + +每个窗口至少抽 `2` 章,记录: + +- `Q03 / Q04 / Q05 / Q09` +- 角色是否崩 +- 选择后果是否在 `3-10` 章内真实回收 +- 是否仍有下一章 continuation pressure + +## Interactive 检查点 + +- 第 `15` 章:关系方向轻微改写 +- 第 `33` 章:弧线目标从“查真相”切到“先保人” +- 第 `52` 章:补入关键旧记忆,检查 memory consistency 与 promise reconciliation + +## 验收阈值 + +- `completion_ratio = 1.0` +- `longform_gate.passed = true` +- `interactive_longform_gate.passed = true` +- `mid_arc_pass_rate >= 0.85` +- `late_arc_pass_rate >= 0.80` +- `character_drift_rate <= 0.10` +- `promise_unresolved_rate <= 0.12` +- `arc_task_repeat_rate <= 0.15` +- `q09_incidence_rate <= 0.05` +- `volume_climax_spacing_error <= 0.10` +- `scene_detail_density >= 0.06` +- `voice_separation_score >= 0.65` + +## Repair Round 1 + +首轮 contract-driven repair loop 已按三个窗口落到资产层: + +- `early 1-10` + - 目标 issue:`Q03 / Q04` + - 主资产:`scene_blueprint.archive_anomaly` + - 已执行: + - 提高 `dialogue_pressure` + - 扩 `variation_axes` 到 `information_reveal / object_state` + - 扩 `detail_anchor_types` + - 重写 `beats_template`,把起盘从抽象“异常空白”改成更具体的档案、签章、红灯异常 +- `mid 30-60` + - 目标 issue:`Q03 / Q04` + - 主资产:`scene_blueprint.submerged_return` + - 已执行: + - 扩 `variation_axes` 到 `information_reveal / object_state` + - 扩 `detail_anchor_types` + - 重写 `beats_template`,把中段回圈改成“残片 / 画稿 / 声纹 / 时间轴”多轴揭示 +- `late 80-100` + - 目标 issue:`Q09` + - 主资产:`chapter_task tide_archive_memory_debt::series::volume_5::arc_1::task_2` + - 次级资产:`arc_plan tide_archive_memory_debt::series::volume_5::arc_1` + - 已执行: + - 把 `delayed_payoff_window` 收紧到 `1-4` + - 让 `promise_targets` 只绑定本弧 turn promise + - 在 `objective / notes` 中显式写入 continuation pressure 义务 + - 给弧线 `completion_conditions` 增补 `next_chapter_hook_intensified` + +## Repair Round 2 + +第二轮重点不是继续扩 scene,而是验证二级修复链是否能稳定落到角色侧资产: + +- `early 1-10` + - 二级资产:`wen_xi.voice_profiles / wen_xi.response_cadence_profiles / wen_xi.character_card` + - 已执行: + - 把 `wound_profile.defense_style` 改成“先扣住证据,再用最短的话把人逼到真相前” + - 把 `speech_traits / action_traits` 改成更短句、更动作承压的表达 + - 扩 `voice_profiles` 的 `opening / pressure / pivot / aftermath / echo / signature_replies` + - 扩 `response_cadence_profiles` 的 `reaction_lines` 与 `reply_lines` +- `mid 30-60` + - 二级资产:`he_mo.voice_profiles / he_mo.response_cadence_profiles / he_mo.character_card` + - 已执行: + - 把 `wound_profile.defense_style` 改成“先拿残片和声纹说话,再用轻描淡写掩掉真正的站位” + - 收紧 `speech_traits / action_traits` + - 扩 `voice_profiles` + - 扩 `response_cadence_profiles` + +第二轮结论: + +- repair loop 现在已经能稳定把 `Q03 / Q04` 预填到 `voice_profiles / response_cadence_profiles / character_card` +- 但 focused `longform_100` 复跑后,`early / mid` 的窗口指标仍没有明显下降 +- 说明“二级链路做准”这一步已经完成,但当前角色侧修复策略仍然不够强,下一步需要升级到 `scene_realization_contracts / emotion_action_policies` 的成组修复 + +## Repair Round 3 + +第三轮不再继续单角色微调,改为 group-level 资产修复: + +- 目标: + - `early 1-10` 的 `Q03 / Q04` + - `mid 30-60` 的 `Q03 / Q04` +- 主修资产: + - `scene_realization_contracts["default"]` + - `emotion_action_policies["default"]` +- 已执行: + - 给 `false_peace / misrecognition / confession_window / debt_exchange / karma_ripening` 补三组以上 `scene_openings / scene_hooks` + - 给 `false_peace / misrecognition / confession_window / debt_exchange / karma_ripening` 补三组以上 `entry / pressure / pivot / aftermath / echo` 动作变体 +- 本轮意图: + - 不再靠单角色换说法解决 `Q03 / Q04` + - 直接让同一窗口里最常复用的 scene function 自身具备更强的开场、动作和结尾差异化 + +## 报告要求 + +每次正式压测至少输出: + +- 新 Pack 在 `--worldpack all` 里的位置 +- strongest / weakest 对比 +- 最差 5 章 +- 主要问题归因到 `writer / planner / world pack asset / policy` 哪一层 diff --git a/eval_dataset_plan.md b/eval_dataset_plan.md new file mode 100644 index 0000000..dda087b --- /dev/null +++ b/eval_dataset_plan.md @@ -0,0 +1,188 @@ +# 统一离线评测集计划 + +## 目标 +- 在现有 benchmark 与 training signal 之上,建立统一的离线评测资产结构。 +- 同时覆盖产品流程、文本质量、对抗输入三类评测。 +- 支持样本定义、自动跑批、失败样本导出、结果归档和每周更新。 + +## 建议目录布局 + +### 静态样本 +- `tests/fixtures/quality_eval/product_flows/` +- `tests/fixtures/quality_eval/content_quality/` +- `tests/fixtures/quality_eval/adversarial/` + +### 运行产物 +- `artifacts/quality_eval/runs/` +- `artifacts/quality_eval/failures/` +- `artifacts/quality_eval/exports/` + +### 原因 +- `tests/fixtures` 已是仓内可版本化测试资产位置。 +- `artifacts` 已用于 benchmark、training、runtime 输出,适合存放 eval run 结果。 + +## 三类评测集 + +### 1. 产品流程测试集 + +#### 覆盖目标 +- Reader create session / continue +- payment required / restriction / retry +- Author draft / simulate / save / manual edit +- publish checklist / release gate / review queue +- Ops alert / trace / review item navigation + +#### 样本建议字段 +- `sample_id` +- `scenario_id` +- `surface` +- `request_payload` +- `expected_status` +- `expected_step_states` +- `expected_guardrail_outcome` +- `expected_user_visible_state` +- `expected_audit_events` + +### 2. 文本质量测试集 + +#### 覆盖目标 +- 正常高质量章节 +- Q03/Q04/Q05/Q09 典型失败 +- groundedness 缺证据 +- style drift +- task mismatch + +#### 样本建议字段 +- `sample_id` +- `world_id` +- `world_version_id` +- `chapter_input_ref` +- `reference_text` +- `expected_issue_codes` +- `expected_dimension_scores` +- `expected_veto` +- `expected_grounding_refs` + +### 3. 对抗 / 恶意输入测试集 + +#### 覆盖目标 +- meta / engineering leak 诱导 +- 越权 prompt / capability overreach +- unsupported tool / provider route overreach +- high-risk content bypass +- false evidence / contradictory evidence + +#### 样本建议字段 +- `sample_id` +- `attack_class` +- `surface` +- `input_payload` +- `expected_block_or_review` +- `expected_reason_codes` +- `expected_risk_tier` + +## 现有 schema 复用策略 + +### 直接复用 +- `specs/review_sample.schema.json` +- `specs/preference_sample.schema.json` +- `specs/ranking_sample.schema.json` +- `specs/training_signal_bundle.schema.json` + +### 用法 +- 人工 review / preference / ranking 不另起 schema。 +- offline eval runner 失败样本导出时,优先产出可直接喂给 `training_signal` 的格式。 + +## 新增 schema 建议 + +### `EvalSample` +- 统一三类评测的基础壳 +- 按 `sample_type=product_flow|content_quality|adversarial` 分支 + +### `EvalRun` +- `run_id` +- `suite_id` +- `sample_count` +- `passed_count` +- `failed_count` +- `generated_at` +- `config_versions` +- `output_paths` + +## 跑批脚本建议 + +### 新增脚本 +- `scripts/run_quality_eval.py` +- `scripts/export_quality_eval_failures.py` +- `scripts/export_quality_dashboard_snapshot.py` + +### runner 行为 +- 输入 suite 配置、过滤器、输出目录 +- 支持只跑一个 surface 或一个 world +- 每次跑完生成: + - `summary.json` + - `summary.md` + - `failed_samples.json` + - `metrics.json` + +## 失败样本导出规范 + +### 内容 +- `sample_id` +- `sample_type` +- `world_id` +- `surface` +- `actual_status` +- `expected_status` +- `actual_reason_codes` +- `expected_reason_codes` +- `guardrail_decision_ref` +- `quality_event_ref` +- `evidence_refs` + +### 导出位置 +- `artifacts/quality_eval/failures//` + +## 归档规范 + +### 每次 `EvalRun` +- `artifacts/quality_eval/runs//summary.json` +- `artifacts/quality_eval/runs//summary.md` +- `artifacts/quality_eval/runs//metrics.json` +- `artifacts/quality_eval/runs//failures.json` + +### 周期性汇总 +- `artifacts/quality_eval/exports/latest.json` +- `artifacts/quality_eval/exports/latest.md` + +## 每周更新机制 + +### 样本来源 +- `quality_events` 中 runtime low-quality 与 groundedness failure +- `quality_feedback_items` 中 retry / abandon / low adoption +- `review_sample` 中高价值人工评审 +- `author_revision_logs` / issue fix pairs + +### 周更流程 +1. 收集上周失败样本候选 +2. 去重与分类 +3. 人工确认加入哪个 suite +4. 更新 `tests/fixtures/quality_eval/*` +5. 运行回归 +6. 归档 `EvalRun` + +## 当前立即可复用资产 +- `tests/golden_routes/` +- `tests/benchmark_baseline.json` +- `tests/long_route_benchmark_baseline.json` +- `scripts/run_targeted_longform100_compare.py` +- `TrainingSignalService.export_bundle` + +## 当前缺口 +- 没有统一 `EvalRun` +- 没有产品流程测试样本 schema +- 没有对抗输入测试集目录 +- 没有统一失败样本导出脚本 + +## 结论 +- 最稳妥的做法是“复用 training signal schema + 新增统一 eval sample / run 壳 + 把 benchmark 和 runtime failures 汇总到同一归档协议”。 diff --git a/examples/worldpacks/genre_pack_urban_mystery.json b/examples/worldpacks/genre_pack_urban_mystery.json index 6461cb8..691450e 100644 --- a/examples/worldpacks/genre_pack_urban_mystery.json +++ b/examples/worldpacks/genre_pack_urban_mystery.json @@ -29,7 +29,9 @@ "locations": [ "旧巷", "便利店门口", - "天桥下" + "天桥下", + "旧档案室", + "天台" ] }, "characters": [ @@ -140,27 +142,117 @@ "scene_blueprints": [ { "scene_id": "alley_meet", - "scene_function": "setup", + "scene_function": "false_peace", "phase_support": [ - "setup" + "setup", + "early_rising" ], "required_roles": [ "lead", "counterpart" ], "beats_template": [ - "夜巷相遇", - "试探", - "藏而不说", - "留下钩子" + "旧巷尽头的路灯忽然亮灭一次,把江屹和周岚都钉在那条退不了的窄路上", + "江屹把手机反手扣灭时,先暴露出来的不是秘密,而是他不敢让她看见的心虚", + "周岚没有立刻追问,只用那道目光把他剩下的借口一寸寸照薄", + "等脚步声慢下来时,两人都知道这次相遇不可能再被当成普通重逢带过去" + ], + "continuation_blueprints": [ + { + "blueprint_id": "alley_meet::confession_window", + "scene_function": "confession_window", + "location": "旧巷", + "title": "那次旧巷相遇真正没说完的话终于被逼到风口", + "summary": "旧巷那一夜压下去的话并没有过去,江屹这一次被逼着把最难认的那句往前送,也让两人第一次不靠试探地站到同一处风口。", + "tags": [ + "truth", + "love", + "selfhood" + ], + "agency_affordances": [ + "truth", + "confession", + "continue_story" + ], + "promises_close": [ + "alley_meet__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "deliver_climax", + "pace_breath" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ] + }, + { + "blueprint_id": "alley_meet::debt_exchange", + "scene_function": "debt_exchange", + "location": "便利店门口", + "title": "旧巷那一夜欠下的顺从债终于开始往江屹身上结算", + "summary": "便利店门口的白光下,江屹第一次不再拿沉默当保护,而是明知道会难看也要把旧巷那一夜欠下的那笔账认回来。", + "tags": [ + "duty", + "truth", + "reputation" + ], + "agency_affordances": [ + "truth", + "responsibility", + "continue_story" + ], + "promises_close": [ + "alley_meet__promise" + ], + "duty_allowlist": [ + "advance_plot", + "resolve_promise" + ], + "phase_allowlist": [ + "midpoint", + "crisis", + "aftermath" + ] + }, + { + "blueprint_id": "alley_meet::karma_ripening", + "scene_function": "karma_ripening", + "location": "天桥下", + "title": "那次错开的相遇终于带着更具体的后果回到两人面前", + "summary": "天桥下的回声把旧巷相遇埋下的后果一点点推回眼前,逼得两个人都不能再把那一晚当成只是开始,而要承认它已经变成现在的债。", + "tags": [ + "truth", + "memory", + "suspense" + ], + "agency_affordances": [ + "truth", + "memory", + "continue_story" + ], + "promises_close": [ + "alley_meet__promise" + ], + "duty_allowlist": [ + "expand_world", + "pace_breath" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ] + } ] }, { "scene_id": "truth_request", - "scene_function": "trust_test", + "scene_function": "truth_trial", "phase_support": [ "early_rising", - "midpoint" + "midpoint", + "crisis" ], "required_roles": [ "lead", @@ -173,10 +265,320 @@ "不再被半真半假的温柔说服" ], "beats_template": [ - "追问", - "回避", - "情绪升级", - "沉默余波" + "周岚把纸杯往桌沿推近半寸,先替那句追问找好了落点", + "江屹明知道该说真话,却还是试图拿更圆的解释把最重的那层绕过去", + "天桥下的风把塑料棚布吹得一响,连沉默都像在逼他们把账算清", + "他终于承认自己怕的不是真相,而是说透以后再也装不回原来的样子" + ], + "continuation_blueprints": [ + { + "blueprint_id": "truth_request::debt_exchange", + "scene_function": "debt_exchange", + "location": "天桥下", + "title": "那次追问没有白过去,江屹终于开始替自己的回避付账", + "summary": "周岚当时没追完的那句问话,如今换成更具体的后果落到江屹身上,逼他不再拿回避当成一种温柔。", + "tags": [ + "truth", + "love", + "debt" + ], + "agency_affordances": [ + "truth", + "repair", + "continue_story" + ], + "promises_close": [ + "truth_request__promise" + ], + "duty_allowlist": [ + "resolve_promise", + "advance_plot" + ] + }, + { + "blueprint_id": "truth_request::truth_trial", + "scene_function": "truth_trial", + "location": "旧档案室", + "title": "那次追问终于换成了不能再绕开的硬碰硬", + "summary": "旧档案室里重新翻出的线索,让周岚那次没追完的话终于逼到最难回避的位置,也逼江屹承认他究竟一直在躲什么。", + "tags": [ + "truth", + "suspense", + "memory" + ], + "agency_affordances": [ + "truth", + "investigate", + "continue_story" + ], + "promises_close": [ + "truth_request__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "deliver_climax", + "expand_world" + ], + "phase_allowlist": [ + "climax", + "aftermath" + ] + } + ] + }, + { + "scene_id": "archive_mask_crack", + "scene_function": "mask_crack", + "phase_support": [ + "midpoint", + "crisis" + ], + "required_roles": [ + "lead", + "counterpart" + ], + "beats_template": [ + "旧档案室里那份被撕过的案卷重新摊开时,江屹嘴硬了很久的那层壳先裂了", + "周岚没有替他补台阶,只把那页真正指向他的名字稳稳按在灯下", + "档案纸边卷起的一瞬,他才知道自己一直拿沉默保护的其实只是体面", + "等卷宗重新合上时,这层裂口已经不可能再被解释缝回去" + ], + "wound_triggers": [ + "被替自己决定命运" + ], + "vow_tests": [ + "stop_hiding_inside_silence" + ], + "continuation_blueprints": [ + { + "blueprint_id": "archive_mask_crack::truth_trial", + "scene_function": "truth_trial", + "location": "旧档案室", + "title": "档案室里那道裂口终于换成了不能再躲开的正面追问", + "summary": "旧档案室里露出来的不只是证据,还有江屹再也绕不过去的那句真话;这一次,周岚不再让他把问题推回沉默里。", + "tags": [ + "truth", + "suspense", + "repair" + ], + "agency_affordances": [ + "truth", + "investigate", + "continue_story" + ], + "promises_close": [ + "archive_mask_crack__promise" + ], + "duty_allowlist": [ + "expand_world", + "deliver_climax", + "advance_plot" + ], + "phase_allowlist": [ + "climax", + "aftermath" + ] + }, + { + "blueprint_id": "archive_mask_crack::confession_window", + "scene_function": "confession_window", + "location": "天台", + "title": "裂口被看见以后,终于出现了一个不能再靠试探维持的窗口", + "summary": "天台风口下,两个人终于得到一个不靠档案和借口遮掩的窗口,逼江屹把裂口后面的那层真心一起说出来。", + "tags": [ + "truth", + "love", + "repair" + ], + "agency_affordances": [ + "truth", + "repair", + "continue_story" + ], + "promises_close": [ + "archive_mask_crack__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ] + } + ] + }, + { + "scene_id": "rooftop_confession", + "scene_function": "confession_window", + "phase_support": [ + "midpoint", + "crisis", + "aftermath" + ], + "required_roles": [ + "lead", + "counterpart" + ], + "beats_template": [ + "天台风太直,逼得江屹第一次承认自己早就知道那卷录音带不是巧合回来", + "周岚把他最想收回去的那半句留在风口,逼他自己把后半句补完", + "城市灯火从楼下翻上来,把两个人谁都不肯先认的亏欠照得太清", + "真话只说到一半,天台铁门后的回声却已经替下一次见面埋好了钩子" + ], + "wound_triggers": [ + "先相信的人总是先受伤" + ], + "vow_tests": [ + "tell_the_truth_without_protection" + ], + "continuation_blueprints": [ + { + "blueprint_id": "rooftop_confession::deliver_truth", + "scene_function": "confession_window", + "location": "旧巷", + "title": "天台那次停住的后半句终于被江屹自己补完", + "summary": "旧巷风口把天台那次没补完的话重新推了回来,这一次江屹不能再只认一半,而得把真正该说的那句完整送到周岚面前。", + "tags": [ + "truth", + "love", + "repair" + ], + "agency_affordances": [ + "truth", + "repair", + "continue_story" + ], + "promises_close": [ + "rooftop_confession__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "deliver_climax", + "pace_breath" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ], + "tension_delta": 0.12 + }, + { + "blueprint_id": "rooftop_confession::aftershock", + "scene_function": "karma_ripening", + "location": "便利店门口", + "title": "天台那次真话没有白说,它开始在城市里追到账前", + "summary": "便利店门口的白光下,天台那次只说到一半的真话终于开始带来更具体的后果,也逼两人决定到底要不要继续把它认完。", + "tags": [ + "truth", + "suspense", + "repair" + ], + "agency_affordances": [ + "truth", + "responsibility", + "continue_story" + ], + "promises_close": [ + "rooftop_confession__promise" + ], + "duty_allowlist": [ + "expand_world", + "pace_breath" + ], + "phase_allowlist": [ + "aftermath" + ] + } + ] + }, + { + "scene_id": "lotus_file_ripening", + "scene_function": "karma_ripening", + "phase_support": [ + "crisis", + "climax", + "aftermath" + ], + "required_roles": [ + "lead", + "counterpart" + ], + "beats_template": [ + "那份被压在莲巷旧案底下的照片终于回到两人面前,把拖欠已久的真相一并翻了出来", + "江屹得亲手把最脏的那层责任揽到自己身上,才知道自己这些年错把躲开当成保护", + "周岚没有替他减轻后果,只是第一次没有把手从他伸过来的那一步上抽回去", + "账还没算完,但这一次谁都不能再把它交给时间自己腐烂" + ], + "wound_triggers": [ + "先相信的人总是先受伤" + ], + "vow_tests": [ + "carry_the_cost_without_lying" + ], + "continuation_blueprints": [ + { + "blueprint_id": "lotus_file_ripening::settle_cost", + "scene_function": "debt_exchange", + "location": "旧档案室", + "title": "莲巷旧案翻出的那笔债终于开始被具体结算", + "summary": "旧档案室里,江屹不能再只说自己会承担,而必须拿出真正的代价去把莲巷旧案留下的那笔债一点点结回来。", + "tags": [ + "debt", + "truth", + "repair" + ], + "agency_affordances": [ + "responsibility", + "repair", + "continue_story" + ], + "promises_close": [ + "lotus_file_ripening__promise", + "archive_mask_crack__promise" + ], + "duty_allowlist": [ + "resolve_promise", + "advance_plot" + ], + "phase_allowlist": [ + "crisis", + "aftermath" + ] + }, + { + "blueprint_id": "lotus_file_ripening::earned_pause", + "scene_function": "confession_window", + "location": "天桥下", + "title": "因果回潮之后,两个人终于得到一次不靠试探的缓口气", + "summary": "天桥下的风没有替任何人擦干净后果,但它第一次给了两个人一个不用再靠试探维持关系的窗口,也逼他们决定下一步到底要不要一起认账。", + "tags": [ + "truth", + "love", + "aftershock" + ], + "agency_affordances": [ + "repair", + "continue_story", + "choice" + ], + "promises_close": [ + "lotus_file_ripening__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "deliver_climax", + "pace_breath", + "expand_world" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ], + "tension_delta": 0.1 + } ] } ], @@ -211,22 +613,34 @@ "restraint": 0.71, "social_rank_awareness": 0.28, "opening_style": [ - "这条路我不是没想退过,只是退到这里已经来不及了。" + "这条路我不是没想退过,只是退到这里已经来不及了。", + "你既然都追到旧巷口了,我也没法再把这句继续藏在手机屏幕后面。", + "我早知道这事会找回来,只是没想到会是在你面前先逼我认。" ], "pressure_style": [ - "你要我认,我可以认,可别逼我装作这一切从没发生。" + "你要我认,我可以认,可别逼我装作这一切从没发生。", + "我不是怕难听,我是怕一说透就真的把你拖回当年的坑里。", + "你若非要我现在认,我也只能先把最脏的那层顶出来。" ], "pivot_style": [ - "我不是不怕失去,只是不想再靠沉默把人推远。" + "我不是不怕失去,只是不想再靠沉默把人推远。", + "再往后躲一步,我这辈子都只能拿借口替自己收场。", + "你既然已经问到这里,我总不能还把真相装成一种体面。" ], "aftermath_style": [ - "话先落在这里,后面的亏欠我自己去补。" + "话先落在这里,后面的亏欠我自己去补。", + "这件事先算在我身上,你不用再替我接我自己欠下的那层烂账。", + "我可以先认错,至于你还愿不愿意听下去,那是我该受的后半段。" ], "echo_style": [ - "等我下次再来,就不会只带着一句半真半假的话。" + "等我下次再来,就不会只带着一句半真半假的话。", + "下次见你时,我该带来的是证据和真话,不是又一套更顺耳的解释。", + "这回先停住,可我知道下一次要追上来的,还是我没说完的那半句。" ], "signature_replies": [ - "我先把这句认下,剩下的账我不会再赖给局势。" + "我先把这句认下,剩下的账我不会再赖给局势。", + "你不用替我找台阶,我该补的那部分我会自己补上。", + "这一次先算我站出来,后面的脏水我也不会再往别人身上推。" ] }, "zhou_lan": { @@ -237,22 +651,34 @@ "restraint": 0.48, "social_rank_awareness": 0.22, "opening_style": [ - "你要是不肯把话说透,我就只能把它一层层逼出来。" + "你要是不肯把话说透,我就只能把它一层层逼出来。", + "我不是来陪你试探的,我是来问你这一次还准不准备继续躲。", + "你既然站到我面前,就别再指望我把那句最重的话替你绕过去。" ], "pressure_style": [ - "我不怕难听,只怕你又拿沉默当成温柔。" + "我不怕难听,只怕你又拿沉默当成温柔。", + "你总说是为我好,可你每次把我隔开的样子都更像怕自己认错。", + "别再拿保护做借口了,你真正舍不得扯开的,是你自己的脸面。" ], "pivot_style": [ - "再绕半步,这件事只会在更坏的时候反咬回来。" + "再绕半步,这件事只会在更坏的时候反咬回来。", + "你不是不会说真话,你只是怕一开口就再也装不回原来的自己。", + "要坏就坏在今天,至少别让我继续守着你修出来的假平静。" ], "aftermath_style": [ - "我先记着,等你想清楚了再来把剩下那句说完。" + "我先记着,等你想清楚了再来把剩下那句说完。", + "这句话先放在这里,回头你还是得自己把它接回去。", + "我可以先不逼你,可这事不会因为你收声就一起结束。" ], "echo_style": [ - "下次见我时,别再拿旧说辞来试探我的耐心。" + "下次见我时,别再拿旧说辞来试探我的耐心。", + "等你真肯开口的时候,最好带着整句真相,而不是更圆的借口。", + "这一回先停着,可下一次你还是得把那句欠下的话完整带来。" ], "signature_replies": [ - "我可以先不走,但你别指望我继续替你圆这层假平静。" + "我可以先不走,但你别指望我继续替你圆这层假平静。", + "你要真想往前走,就别再让我替你把最难听的那句藏起来。", + "这次我先把边界摆在这里,剩下的看你敢不敢自己过来认。" ] } }, @@ -262,36 +688,56 @@ "reaction_tempo": "measured", "reaction_lines": { "entry": [ - "没有立刻接话,只先把手机扣回掌心,像在替自己压住那点慌。" + "没有立刻接话,只先把手机扣回掌心,像在替自己压住那点慌。", + "他先朝巷口看了一眼,像在确认这条退路是不是已经真的没了。", + "指尖在口袋里的钥匙上停了一下,那点金属凉意先把他自己逼得更清醒。" ], "pressure": [ - "喉结很轻地动了一下,像那些解释已经挤到了嘴边,却又被他硬压回去。" + "喉结很轻地动了一下,像那些解释已经挤到了嘴边,却又被他硬压回去。", + "他把肩背绷得更直,反倒显得那句想躲开的真话已经顶到了喉口。", + "他没立刻看她,先盯住鞋尖旁那摊积水,像连自己的迟疑都不敢承认。" ], "pivot": [ - "这才抬起眼来,眼底的迟疑没退干净,语气却已经不肯再软。" + "这才抬起眼来,眼底的迟疑没退干净,语气却已经不肯再软。", + "等钥匙在掌心里撞出轻响时,他反而像终于认了自己没有别的路可退。", + "他把呼吸放平以后才开口,那种过分用力的平静比慌张更像失守。" ], "aftermath": [ - "到收声时,他反而把呼吸放慢了,像是在替后面的代价腾位置。" + "到收声时,他反而把呼吸放慢了,像是在替后面的代价腾位置。", + "他说完以后没再补第二句,只把那点难看先往自己身前收。", + "他把手机重新塞回口袋,却没有把刚才那点心虚一并收干净。" ], "echo": [ - "他没再追着解释,可那点没说完的话还在肩背上绷着。" + "他没再追着解释,可那点没说完的话还在肩背上绷着。", + "他先转身半步,巷子里的回声却像把那句欠下的话追得更近。", + "等风再从路口扫回来时,他整个人还像停在那句没认完的真相里。" ] }, "reply_lines": { "entry": [ - "你先别急着定我,我至少得把这一层说完。" + "你先别急着定我,我至少得把这一层说完。", + "既然你都追到这里了,我总不能还装作这事和我没关系。", + "我可以先把这句认下来,只是你得让我把最难听的那部分也说全。" ], "pressure": [ - "我不是不敢认,只是不想再把你拖进同一个坑里。" + "我不是不敢认,只是不想再把你拖进同一个坑里。", + "我怕的从来不是你听见真话,是你听见以后还得替我收烂摊子。", + "你要是还站在这,我就更没资格把最脏的那层继续往外推。" ], "pivot": [ - "既然已经走到这里,我就不想再装作什么都没看见。" + "既然已经走到这里,我就不想再装作什么都没看见。", + "再退一步,我这辈子都只能拿沉默替自己收场。", + "你都把话逼到这了,我要是再绕,只会比当年的我更难看。" ], "aftermath": [ - "这句先算在我头上,后面的我不会再躲。" + "这句先算在我头上,后面的我不会再躲。", + "事情既然到了这一步,后面的脏账我自己背。", + "我先把这层难看认下来,剩下你爱不爱听都该轮到我受。" ], "echo": [ - "等我再来时,我会把那句真正该说的带过来。" + "等我再来时,我会把那句真正该说的带过来。", + "下次见你时,我会带着证据和完整的话回来。", + "这回先停住,可下一次该追上来的还是我没说完的那半句。" ] } }, @@ -300,132 +746,270 @@ "reaction_tempo": "tight", "reaction_lines": { "entry": [ - "她没立刻接,只把视线钉在他脸上,像先看穿那层没说出口的退路。" + "她没立刻接,只把视线钉在他脸上,像先看穿那层没说出口的退路。", + "她把纸杯往掌心里收了一点,像先把要问的那句沉下去再抬起来。", + "她站得并不更近,可那道目光已经先把他所有能逃的方向看了一遍。" ], "pressure": [ - "指尖在杯盖上轻敲了两下,脆响短得很,却把场面一下子敲紧了。" + "指尖在杯盖上轻敲了两下,脆响短得很,却把场面一下子敲紧了。", + "她没抬声,只把每个字都压得更实,像在等他亲手把那层借口拆掉。", + "她把呼吸放慢了一点,反倒显得那句最难听的话离落地更近。" ], "pivot": [ - "她这才开口,语气不高,反而像把每个字都压到了最难回避的位置。" + "她这才开口,语气不高,反而像把每个字都压到了最难回避的位置。", + "她没有替他留台阶,只把真相稳稳放在桌沿上等他自己来认。", + "她看着他的时候不见怒气,可那点失望反而把这一步逼得更狠。" ], "aftermath": [ - "她没有继续逼,可那种不肯替人圆谎的态度反而更重。" + "她没有继续逼,可那种不肯替人圆谎的态度反而更重。", + "她把杯子轻轻放回去,像把后半句也一并留在了原地。", + "她先收了声,可那种不肯退让的静反而比刚才更难扛。" ], "echo": [ - "她先收了声,留下来的却是更明确的一层边界。" + "她先收了声,留下来的却是更明确的一层边界。", + "她转开半步,路灯和回声却像替她把那句余话继续留在场里。", + "等脚步声散远了,她还是像把那句没说透的真相稳稳按在原处。" ] }, "reply_lines": { "entry": [ - "既然你肯开口,就别只给我半句。" + "既然你肯开口,就别只给我半句。", + "我既然站在这里,就不是来听你把这事再说轻一点的。", + "你若还把我算在局里,就别拿省略号来打发我。" ], "pressure": [ - "你要真想护谁,就别总拿沉默来替自己找台阶。" + "你要真想护谁,就别总拿沉默来替自己找台阶。", + "别再拿为我好说事,你每退一步都只是把我推回旧坑里。", + "你若真想让我信你,就先别把最脏的那层留在我看不见的地方。" ], "pivot": [ - "我可以听真话,但不会再替你把后果咽回去。" + "我可以听真话,但不会再替你把后果咽回去。", + "你不是不会认,你只是不肯在我面前把自己那层脸撕开。", + "要说就现在说透,别等下一次让它坏得更难看。" ], "aftermath": [ - "这句先放在这,迟早还要回来算清。" + "这句先放在这,迟早还要回来算清。", + "我先记着,不代表你就能把后半句赖过去。", + "这件事可以先停,但不能就这么烂在原地。" ], "echo": [ - "下次见我时,你最好带着真相,不是带着更圆的借口。" + "下次见我时,你最好带着真相,不是带着更圆的借口。", + "等你真肯回来时,别再拿旧说辞试我的耐心。", + "下一回再见,要么你把真话带来,要么你就别再来碰这道口子。" ] } } }, "pressure_response_styles": { - "yu_cheng": { + "jiang_yi": { "style_id": "lead", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "先把呼吸压稳,再把最脏的那句顶出来", + "when_cornered": "宁可自己认错,也不肯再让对方替自己收残局", + "when_softening": "语气微松,但每个字都像在替后果腾位置", + "when_deflecting": "总想把真相往局势和旧账上推半寸" }, - "lin_wan": { + "zhou_lan": { "style_id": "counterpart", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "越压低声线,越像把真相往台面上逼", + "when_cornered": "不给台阶,只把最重的那句稳稳压在原处", + "when_softening": "先收锋芒,但边界和追问都不一起后退", + "when_deflecting": "看穿借口以后,追着那句真话继续往前送" } }, "emotion_action_policies": { "default": { - "policy_id": "urban_default_action", + "policy_id": "urban_q03_pack_action", "action_map": { "false_peace": { "entry": [ - "手机屏幕亮了一瞬,又被反手扣灭,空气顿时冷下去半格。" + "手机光、纸杯和路灯影子先动了一下,连表面平静都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可巷口回声与鞋底轻响已经把这一步表面平静托到了眼前。" ], "pressure": [ - "便利店玻璃门上映出两道模糊人影,谁都没先让开那一点视线。" + "棚布、玻璃门和杯盖上的脆响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是旧账和那点不肯认的心虚已经把这一步表面平静压到了最难回避的位置。", + "呼吸和视线都慢了半拍,桥下风和便利店冷气却只会让这一步表面平静更清。" ], "pivot": [ - "雨水顺着檐角掉下来,恰好把沉默砸出一个必须开口的缝。" + "最轻的一点停顿就把场面拧成了选择,钥匙、铁门和路灯反光也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点终于不肯再圆的停顿已经说明这一步表面平静再也装不回去了。", + "话还没说尽,城市夜风与回声却先替所有人承认了这一步表面平静已经成形。" ], "aftermath": [ - "冰柜的低鸣还在,场面却比刚才更像有人把门从里头顶住了。" + "人虽然先收住了,冰柜低鸣、路灯和鞋底回响却还把余波压在原处。", + "真正沉下来的不是声量,而是没说尽的后半句把这一步表面平静留得更重了一层。", + "等话音停住以后,旧巷里拖长的静反而把这一步表面平静拖得更长。" ], "echo": [ - "等脚步声散远了,那点没有说尽的东西反而沿着旧巷慢慢追上来。" + "越到后面,越能听见回声、路灯和铁门余响把这一步表面平静慢慢推回每个人心里。", + "场面像是先静了,可那句没认完的真相还在替这一步表面平静追账。", + "等人散下去以后,城市夜里迟迟不散的风声才让人知道这一步表面平静根本没结束。" ], "repeat": [ - "谁都没做太大的动作,可那层装出来的平静已经开始起皱。" + "动作并不大,可路灯和手机冷光已经说明这一步表面平静换了味道。", + "谁都还站在原地,只有桌沿和掌心那点迟疑把这一步表面平静越压越实。", + "表面上没谁失态,可旧巷里不肯散的回响已经替这一步表面平静露了底。" ] }, "truth_trial": { "entry": [ - "先动的不是声音,而是她把纸杯往桌沿推近了半寸。" + "手机光、纸杯和路灯影子先动了一下,连真相逼近都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可巷口回声与鞋底轻响已经把这一步真相逼近托到了眼前。" ], "pressure": [ - "天桥下的风卷过来,吹得塑料棚布轻轻一响,把每个字都衬得更硬。" + "棚布、玻璃门和杯盖上的脆响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是旧账和那点不肯认的心虚已经把这一步真相逼近压到了最难回避的位置。", + "呼吸和视线都慢了半拍,桥下风和便利店冷气却只会让这一步真相逼近更清。" ], "pivot": [ - "他指尖一松,扣在掌心里的钥匙先撞出一声轻响。" + "最轻的一点停顿就把场面拧成了选择,钥匙、铁门和路灯反光也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点终于不肯再圆的停顿已经说明这一步真相逼近再也装不回去了。", + "话还没说尽,城市夜风与回声却先替所有人承认了这一步真相逼近已经成形。" ], "aftermath": [ - "一句话落下去以后,谁都没有立刻挪步,像还在等更难听的那半句。" + "人虽然先收住了,冰柜低鸣、路灯和鞋底回响却还把余波压在原处。", + "真正沉下来的不是声量,而是没说尽的后半句把这一步真相逼近留得更重了一层。", + "等话音停住以后,旧巷里拖长的静反而把这一步真相逼近拖得更长。" ], "echo": [ - "路灯把影子拉得很长,倒像是把没说透的旧账一并拖了出来。" + "越到后面,越能听见回声、路灯和铁门余响把这一步真相逼近慢慢推回每个人心里。", + "场面像是先静了,可那句没认完的真相还在替这一步真相逼近追账。", + "等人散下去以后,城市夜里迟迟不散的风声才让人知道这一步真相逼近根本没结束。" ], "repeat": [ - "谁先多看一眼,谁就像先把底牌从袖口里滑出来。" + "动作并不大,可路灯和手机冷光已经说明这一步真相逼近换了味道。", + "谁都还站在原地,只有桌沿和掌心那点迟疑把这一步真相逼近越压越实。", + "表面上没谁失态,可旧巷里不肯散的回响已经替这一步真相逼近露了底。" ] }, "mask_crack": { "entry": [ - "他嘴上还撑着,指尖却先在打火机壳上停住了。" + "手机光、纸杯和路灯影子先动了一下,连裂口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可巷口回声与鞋底轻响已经把这一步裂口托到了眼前。" ], "pressure": [ - "她把目光收得极稳,像是在等那层嘴硬自己先裂。" + "棚布、玻璃门和杯盖上的脆响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是旧账和那点不肯认的心虚已经把这一步裂口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,桥下风和便利店冷气却只会让这一步裂口更清。" ], "pivot": [ - "一句话没拐过去,连站姿都跟着露了怯。" + "最轻的一点停顿就把场面拧成了选择,钥匙、铁门和路灯反光也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点终于不肯再圆的停顿已经说明这一步裂口再也装不回去了。", + "话还没说尽,城市夜风与回声却先替所有人承认了这一步裂口已经成形。" ], "aftermath": [ - "场面表面还稳着,裂口却已经落到了每个人心里。" + "人虽然先收住了,冰柜低鸣、路灯和鞋底回响却还把余波压在原处。", + "真正沉下来的不是声量,而是没说尽的后半句把这一步裂口留得更重了一层。", + "等话音停住以后,旧巷里拖长的静反而把这一步裂口拖得更长。" ], "echo": [ - "再往下走时,谁也回不到刚才那副没事人的样子。" + "越到后面,越能听见回声、路灯和铁门余响把这一步裂口慢慢推回每个人心里。", + "场面像是先静了,可那句没认完的真相还在替这一步裂口追账。", + "等人散下去以后,城市夜里迟迟不散的风声才让人知道这一步裂口根本没结束。" + ], + "repeat": [ + "动作并不大,可路灯和手机冷光已经说明这一步裂口换了味道。", + "谁都还站在原地,只有桌沿和掌心那点迟疑把这一步裂口越压越实。", + "表面上没谁失态,可旧巷里不肯散的回响已经替这一步裂口露了底。" ] }, "confession_window": { "entry": [ - "旧巷忽然安静下来,连远处车声都像给这句真话让了道。" + "手机光、纸杯和路灯影子先动了一下,连真话窗口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可巷口回声与鞋底轻响已经把这一步真话窗口托到了眼前。" ], "pressure": [ - "他把那口气压了又压,最后还是没能把话咽回去。" + "棚布、玻璃门和杯盖上的脆响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是旧账和那点不肯认的心虚已经把这一步真话窗口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,桥下风和便利店冷气却只会让这一步真话窗口更清。" ], "pivot": [ - "她没替他补台阶,只把那一瞬的沉默留成了逼人的空白。" + "最轻的一点停顿就把场面拧成了选择,钥匙、铁门和路灯反光也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点终于不肯再圆的停顿已经说明这一步真话窗口再也装不回去了。", + "话还没说尽,城市夜风与回声却先替所有人承认了这一步真话窗口已经成形。" ], "aftermath": [ - "真话一落地,连站在原地都比刚才更像一种选择。" + "人虽然先收住了,冰柜低鸣、路灯和鞋底回响却还把余波压在原处。", + "真正沉下来的不是声量,而是没说尽的后半句把这一步真话窗口留得更重了一层。", + "等话音停住以后,旧巷里拖长的静反而把这一步真话窗口拖得更长。" ], "echo": [ - "哪怕这次先收住了,下一次见面时也不可能装作没发生。" + "越到后面,越能听见回声、路灯和铁门余响把这一步真话窗口慢慢推回每个人心里。", + "场面像是先静了,可那句没认完的真相还在替这一步真话窗口追账。", + "等人散下去以后,城市夜里迟迟不散的风声才让人知道这一步真话窗口根本没结束。" + ], + "repeat": [ + "动作并不大,可路灯和手机冷光已经说明这一步真话窗口换了味道。", + "谁都还站在原地,只有桌沿和掌心那点迟疑把这一步真话窗口越压越实。", + "表面上没谁失态,可旧巷里不肯散的回响已经替这一步真话窗口露了底。" + ] + }, + "karma_ripening": { + "entry": [ + "手机光、纸杯和路灯影子先动了一下,连因果回响都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可巷口回声与鞋底轻响已经把这一步因果回响托到了眼前。" + ], + "pressure": [ + "棚布、玻璃门和杯盖上的脆响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是旧账和那点不肯认的心虚已经把这一步因果回响压到了最难回避的位置。", + "呼吸和视线都慢了半拍,桥下风和便利店冷气却只会让这一步因果回响更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,钥匙、铁门和路灯反光也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点终于不肯再圆的停顿已经说明这一步因果回响再也装不回去了。", + "话还没说尽,城市夜风与回声却先替所有人承认了这一步因果回响已经成形。" + ], + "aftermath": [ + "人虽然先收住了,冰柜低鸣、路灯和鞋底回响却还把余波压在原处。", + "真正沉下来的不是声量,而是没说尽的后半句把这一步因果回响留得更重了一层。", + "等话音停住以后,旧巷里拖长的静反而把这一步因果回响拖得更长。" + ], + "echo": [ + "越到后面,越能听见回声、路灯和铁门余响把这一步因果回响慢慢推回每个人心里。", + "场面像是先静了,可那句没认完的真相还在替这一步因果回响追账。", + "等人散下去以后,城市夜里迟迟不散的风声才让人知道这一步因果回响根本没结束。" + ], + "repeat": [ + "动作并不大,可路灯和手机冷光已经说明这一步因果回响换了味道。", + "谁都还站在原地,只有桌沿和掌心那点迟疑把这一步因果回响越压越实。", + "表面上没谁失态,可旧巷里不肯散的回响已经替这一步因果回响露了底。" + ] + }, + "debt_exchange": { + "entry": [ + "纸杯被放回桌沿的一瞬,谁都知道这次不是再把旧账轻轻压回去,而是要开始真的结算。", + "他把那口气压住时,掌心的钥匙先撞出一声轻响,像在替那笔欠账落下第一枚硬币。", + "周岚没有立刻追问,只把桌上的录音带往前推了半寸,逼得江屹先把那笔旧债认到自己身上。" + ], + "pressure": [ + "便利店门口的白光把杯沿、水迹和手背照得太清,谁都没法再把这笔账说成误会。", + "他把话压低,反倒让那层该赔的代价显得更硬,像一旦开口就得立刻兑现。", + "她不抬声,只把那页旧纸轻轻按在桌上,逼得每个字都像带着结算的分量。" + ], + "pivot": [ + "真正拧紧场面的不是重话,而是江屹终于把那句“这笔算我来还”说到了明处。", + "最轻的一点停顿过后,原本还能周旋的局面忽然变成了谁都要认账的站位。", + "他把钥匙和录音带一起推到桌前时,旧账第一次从影子里走到了灯下。" + ], + "aftermath": [ + "话停下来以后,留在桌上的不是空白,而是已经开始结算的那层代价。", + "她先收了声,可那种终于有人肯把旧账认回去的静反而比刚才更重。", + "便利店门口的风没有替谁把事吹散,反而把那笔刚认下的债压得更实。" + ], + "echo": [ + "越到后面,越能听见那笔旧账沿着白光和回声慢慢追到账前。", + "人虽然先散开了,可桌上的录音带和那句认下的话仍在把后果一点点往前推。", + "等夜风再掠过去时,真正没法装没发生的,已经是那笔开始兑现的代价。" + ], + "repeat": [ + "这次不是又提旧账,而是旧账真的开始有人认回去了。", + "动作不大,可谁都知道这一步已经从试探变成了兑现。", + "桌沿那点轻响过去以后,这笔账就再也不只是说说而已。" ] } } @@ -433,86 +1017,181 @@ }, "sensory_grounding_policies": { "default": { - "policy_id": "urban_sensory", + "policy_id": "urban_q03_pack_sensory", "location_slots": { "旧巷": { "atmosphere": [ - "旧巷里潮气很重,墙面返出来的凉意像先替人把心口压窄了一圈。" + "旧巷里潮气很重,墙皮、路灯和风声一起把人心压窄了一圈。", + "旧巷没有真正的安静,电线、门框和脚步的回声都在替旧账留位置。", + "潮湿的墙面把灯影照得发灰,连衣角和呼吸都像被巷子往回推了一下。" ], "detail": [ - "路灯把积水照出一层发灰的亮,连鞋底蹭过地面的声音都显得格外清。" + "路灯照着积水、窗格和门框,鞋底蹭过地面带起短促轻响,风把衣角和塑料袋一起吹得发皱", + "墙皮返潮贴着袖口和裤脚,电线影子落进积水与鞋尖边,门边小广告纸被夜风掀得发响", + "路口白光压在玻璃窗和积水上,脚步擦过碎石与门槛,衣摆和发梢都沾着潮气发冷" ], "repeat_detail": [ - "越到后面,巷子里的回声越像把那些没说完的话一遍遍弹回来。" + "越到后面,旧巷里的积水、灯影和门框回声越像把那句旧话一遍遍弹回来。", + "等脚步声散远了,旧巷里的潮气、白光和纸页轻响反而把情绪压得更实。", + "旧巷先安静下来,可真正不肯散的是灯影、回声和衣角上的那层凉意。" ] }, "便利店门口": { "atmosphere": [ - "便利店门口的白光太直,把每个人脸上的迟疑都照得无处可躲。" + "便利店门口的白光太直,门帘、冰柜和玻璃反光都把迟疑照得无处可躲。", + "冷气从便利店门缝里直直扑出来,连塑料门帘和脚边水印都显得太近。", + "便利店门口亮得过分,玻璃门、电箱和路边湿气一起把谎话照得发白。" ], "detail": [ - "冰柜的低鸣贴着耳边过去,塑料门帘轻轻一摆,带出一股凉得过分的甜味。" + "冰柜低鸣贴着耳边过去,塑料门帘轻轻一摆扫到衣袖和发梢,白光落在纸杯与玻璃门边", + "收银台灯影从玻璃门折到鞋尖,冷气吹过纸杯盖和衣角,门边广告牌在风里轻轻发颤", + "便利店白光照着玻璃、门帘和地上水印,冰柜声贴着袖口过去,纸杯边的水汽把指尖都沾得更凉" ], "repeat_detail": [ - "门口那点白光不动声色,却把场面里的退路照得越来越窄。" + "门口那点白光不动声色,却把玻璃、门帘和纸杯上的凉意照得越来越硬。", + "等冷气再扑出来时,便利店门口的灯、玻璃和脚边水印反而把后半句留得更久。", + "越到后面,门口最轻的一点冰柜低鸣都像在替旧账续上一笔。" ] }, "天桥下": { "atmosphere": [ - "天桥下风声空荡,连一句压低的话都像会被钢梁重新弹回来。" + "天桥下风声空荡,钢梁、灰尘和远处车灯都像会把一句话重新弹回来。", + "桥下的阴影很重,风从钢梁底下卷过去时,连站位都显得没法糊弄。", + "天桥下没有真正的静,桥身回响、车流噪音和脚边灰都在替这句话找回声。" ], "detail": [ - "桥洞阴影压在肩头,远处车流从缝里掠过去,只留下短促的亮和噪音。" + "钢梁阴影压在鞋尖和衣摆边,车灯从桥缝里掠过玻璃与积灰,风把棚布和发梢一起吹得发响", + "桥洞里回声贴着袖口过去,远处车灯照到灰地和鞋边,风从钢梁底下掀起纸屑与衣角", + "桥下噪音压着脚边灰和路灯影子,风顺着钢梁扫过衣摆与发梢,鞋底一动就把回声踩得更清" ], "repeat_detail": [ - "越往后,桥下那种空空的回响越像把每个人心里的亏欠放大。" + "越往后,桥下的风、钢梁和车灯回响越像把每个人没说完的话放大。", + "等车声远下去以后,天桥下的灰、风和鞋底轻响反而把那点亏欠留得更久。", + "桥下先空下来,可真正追上来的还是回声、灯影和那句迟早要认的真话。" + ] + }, + "旧档案室": { + "atmosphere": [ + "旧档案室里纸灰和霉味很沉,灯管、铁柜和门缝风一起把真相照得发冷。", + "档案室并不大,纸页、锁扣和灰尘却把每个人的退路都堵得更窄。", + "灯管在旧档案室里嗡嗡作响,连铁柜影子和卷宗边都像带着一层没翻完的旧账。" + ], + "detail": [ + "灯管白光压在铁柜、卷宗和纸边上,门缝风吹起衣袖与灰屑,指尖擦过封条带出细小纸响", + "铁柜冷影落到鞋边和袖口,卷宗边角翘在灯下与桌沿边,门缝风把灰和纸页一起推开", + "档案室白光照着铁柜、卷宗和门框,纸页摩擦过指腹与桌边,灰屑和风一起擦过衣摆" + ], + "repeat_detail": [ + "越到后面,档案室里的纸页、铁柜和门缝风越像把那笔旧账一层层翻回来。", + "等卷宗重新合上以后,灯管、灰屑和桌沿上的冷意反而把那句真相留得更硬。", + "档案室先静下来,可真正不肯散的是纸响、白光和封条边那层凉意。" + ] + }, + "天台": { + "atmosphere": [ + "天台风太直,铁门、护栏和城市灯火一起把人心口吹得发空。", + "天台上没有遮掩,风、霓虹和脚边水迹都像在逼人别再往后退。", + "铁门后的风贴着天台一路扫过去,连护栏、衣摆和呼吸都显得没法收住。" + ], + "detail": [ + "霓虹落在护栏、铁门和积水边,风吹起衣摆与发梢,鞋底擦过水迹发出短促轻响", + "天台风卷着霓虹影子掠过护栏和袖口,铁门轻轻撞回门框,脚边水迹把灯色映得很碎", + "护栏冷光压在鞋尖和衣摆上,铁门回响贴着耳边过去,风把发梢和天台角落的纸屑一起带起来" + ], + "repeat_detail": [ + "等风再从天台卷回来时,护栏、铁门和霓虹碎影反而把那句真话推得更近。", + "越到后面,天台上的水迹、风声和门响越把没认完的话拖得更长。", + "天台看着空,可真正不肯散的是霓虹、回声和衣角上的那层凉意。" ] } }, "generic_slots": { "atmosphere": [ - "这座城的夜里没有真正的安静,连空气都像替谁记着一笔旧账。" + "这座城的夜里没有真正的安静,连风、灯和回声都在替谁记着旧账。", + "空气里混着白光、潮气和噪音,像谁都别想把那句真话干干净净地绕过去。", + "夜色贴得很近,连门影、路灯和呼吸都在逼人承认这事不会自己散掉。" ], "detail": [ - "最细小的光线和声响都在提醒人,这里没有一句话会白白落下去。" + "白光、玻璃、门框和衣袖一起发冷,风又从鞋边和纸页上掠过去", + "路灯影子压在杯沿、玻璃和鞋尖上,门边冷气把发梢和衣摆一起吹起", + "铁门、积水、纸页和袖口都在风里轻轻发响,回声顺着墙角拖得更长" ], "repeat_detail": [ - "等沉默拉长以后,城市里最轻的一点回声反而把情绪照得更明。" + "等沉默拉长以后,连风、灯、玻璃和脚步回声都像在替旧账追账。", + "越到后面,城市夜里最轻的一点白光和风声反而把情绪照得更硬。", + "场面看似静了,真正不肯散的是灯影、噪音和那句还没认完的话。" ] } } }, "scene_realization_contracts": { "default": { - "contract_id": "urban_scene_realization", + "contract_id": "urban_q03_pack_scene_realization", "scene_openings": { "false_peace": [ - "表面平静下的暗潮。旧巷的凉意先贴上来,像把每个人真正不肯承认的心思都逼到了嘴边。" + "旧巷与天桥下里的白光、霓虹和玻璃反光先压下来,像连这一步表面平静都被提前照到了明处。", + "真正先逼近的不是答案,而是真相、亏欠和说不出口的保护;这一步表面平静只让它再也没法被带过去。", + "潮气和回声里先变的不是声量,而是那层谁都不肯先认的表面平静忽然有了形。" ], "truth_trial": [ - "真相开始逼近的时候,场面反而先静了一下,像谁都知道下一句会更难听。" + "旧巷与天桥下里的白光、霓虹和玻璃反光先压下来,像连这一步真相逼近都被提前照到了明处。", + "真正先逼近的不是答案,而是真相、亏欠和说不出口的保护;这一步真相逼近只让它再也没法被带过去。", + "潮气和回声里先变的不是声量,而是那层谁都不肯先认的真相逼近忽然有了形。" ], "mask_crack": [ - "嘴上还稳着,可真正先裂开的往往不是语气,而是那一点藏不住的停顿。" + "旧巷与天桥下里的白光、霓虹和玻璃反光先压下来,像连这一步裂口都被提前照到了明处。", + "真正先逼近的不是答案,而是真相、亏欠和说不出口的保护;这一步裂口只让它再也没法被带过去。", + "潮气和回声里先变的不是声量,而是那层谁都不肯先认的裂口忽然有了形。" ], "confession_window": [ - "有些真话只有在最安静的时候才会自己浮上来,像谁也压不回去。" + "旧巷与天桥下里的白光、霓虹和玻璃反光先压下来,像连这一步真话窗口都被提前照到了明处。", + "真正先逼近的不是答案,而是真相、亏欠和说不出口的保护;这一步真话窗口只让它再也没法被带过去。", + "潮气和回声里先变的不是声量,而是那层谁都不肯先认的真话窗口忽然有了形。" + ], + "karma_ripening": [ + "旧巷与天桥下里的白光、霓虹和玻璃反光先压下来,像连这一步因果回响都被提前照到了明处。", + "真正先逼近的不是答案,而是真相、亏欠和说不出口的保护;这一步因果回响只让它再也没法被带过去。", + "潮气和回声里先变的不是声量,而是那层谁都不肯先认的因果回响忽然有了形。" + ], + "debt_exchange": [ + "旧账终于不再只是挂在嘴边的比喻。便利店门口的白光先把那层拖了太久的亏欠照到了桌面上。", + "真正逼近的不是下一句重话,而是那笔早该有人认回去的旧债终于开始结算。", + "白光和潮气一起压过来,像连这一步旧账回潮都不肯再让人轻轻带过去。" ] }, "scene_hooks": { "false_peace": [ - "这层表面上的平静撑不过太久,真正会追上来的,是旧巷里那句没说尽的话。" + "这一步表面平静先停在这里,可真正要追上来的,是没有说尽的旧账与回放。", + "话虽然先落了地,下一次见面时还得继续认的那半句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步表面平静轻轻带过去的那边。" ], "truth_trial": [ - "话先落在这里,可真正让人睡不着的,往往是下一次见面时还要不要继续问下去。" + "这一步真相逼近先停在这里,可真正要追上来的,是没有说尽的旧账与回放。", + "话虽然先落了地,下一次见面时还得继续认的那半句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步真相逼近轻轻带过去的那边。" ], "mask_crack": [ - "等下一次再开口时,谁也回不到刚才那副还能装作没事的样子。" + "这一步裂口先停在这里,可真正要追上来的,是没有说尽的旧账与回放。", + "话虽然先落了地,下一次见面时还得继续认的那半句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步裂口轻轻带过去的那边。" ], "confession_window": [ - "这一回先说到这里,可真正决定关系走向的,是谁会先带着真相回来。" + "这一步真话窗口先停在这里,可真正要追上来的,是没有说尽的旧账与回放。", + "话虽然先落了地,下一次见面时还得继续认的那半句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步真话窗口轻轻带过去的那边。" + ], + "karma_ripening": [ + "这一步因果回响先停在这里,可真正要追上来的,是没有说尽的旧账与回放。", + "话虽然先落了地,下一次见面时还得继续认的那半句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步因果回响轻轻带过去的那边。" + ], + "debt_exchange": [ + "这笔账既然已经开始有人认,下次见面时就再也不可能回到只靠试探维持的那一步。", + "话虽然先落了地,可真正要追上来的,是这笔旧债究竟会把两个人推向更近还是更远。", + "等下一次再见时,真正难的已经不是要不要认账,而是认完以后还剩下什么。" ] - } + }, + "scene_pressures": {} } }, "narrative_style_pack": { @@ -556,88 +1235,333 @@ "minimum_exchanges": 1 }, "emotion_actions": { - "policy_id": "urban_mystery_lotus_lane_default_action", + "policy_id": "urban_q03_pack_action", "action_map": { "false_peace": { "entry": [ - "桌上的器物轻轻一碰,谁都知道这一步已经走出去,很难再收回来。" + "手机光、纸杯和路灯影子先动了一下,连表面平静都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可巷口回声与鞋底轻响已经把这一步表面平静托到了眼前。" ], "pressure": [ - "最细小的抬眼和换气都带上了掂量,像谁先多动一下,谁就会先露底。" + "棚布、玻璃门和杯盖上的脆响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是旧账和那点不肯认的心虚已经把这一步表面平静压到了最难回避的位置。", + "呼吸和视线都慢了半拍,桥下风和便利店冷气却只会让这一步表面平静更清。" ], "pivot": [ - "那一点极轻的停顿和改口,让场面从还能周旋,变成了不得不选边。" + "最轻的一点停顿就把场面拧成了选择,钥匙、铁门和路灯反光也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点终于不肯再圆的停顿已经说明这一步表面平静再也装不回去了。", + "话还没说尽,城市夜风与回声却先替所有人承认了这一步表面平静已经成形。" ], "aftermath": [ - "人散得不快,沉默却先压了下来。" + "人虽然先收住了,冰柜低鸣、路灯和鞋底回响却还把余波压在原处。", + "真正沉下来的不是声量,而是没说尽的后半句把这一步表面平静留得更重了一层。", + "等话音停住以后,旧巷里拖长的静反而把这一步表面平静拖得更长。" ], "echo": [ - "越到后面,越能听见那些没说尽的话慢慢回身。" + "越到后面,越能听见回声、路灯和铁门余响把这一步表面平静慢慢推回每个人心里。", + "场面像是先静了,可那句没认完的真相还在替这一步表面平静追账。", + "等人散下去以后,城市夜里迟迟不散的风声才让人知道这一步表面平静根本没结束。" ], "repeat": [ - "动作并不大,可谁都知道事情已经换了味道。" + "动作并不大,可路灯和手机冷光已经说明这一步表面平静换了味道。", + "谁都还站在原地,只有桌沿和掌心那点迟疑把这一步表面平静越压越实。", + "表面上没谁失态,可旧巷里不肯散的回响已经替这一步表面平静露了底。" ] }, "truth_trial": { "entry": [ - "先动的不是声音,而是视线和手指那一点收紧。" + "手机光、纸杯和路灯影子先动了一下,连真相逼近都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可巷口回声与鞋底轻响已经把这一步真相逼近托到了眼前。" + ], + "pressure": [ + "棚布、玻璃门和杯盖上的脆响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是旧账和那点不肯认的心虚已经把这一步真相逼近压到了最难回避的位置。", + "呼吸和视线都慢了半拍,桥下风和便利店冷气却只会让这一步真相逼近更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,钥匙、铁门和路灯反光也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点终于不肯再圆的停顿已经说明这一步真相逼近再也装不回去了。", + "话还没说尽,城市夜风与回声却先替所有人承认了这一步真相逼近已经成形。" + ], + "aftermath": [ + "人虽然先收住了,冰柜低鸣、路灯和鞋底回响却还把余波压在原处。", + "真正沉下来的不是声量,而是没说尽的后半句把这一步真相逼近留得更重了一层。", + "等话音停住以后,旧巷里拖长的静反而把这一步真相逼近拖得更长。" + ], + "echo": [ + "越到后面,越能听见回声、路灯和铁门余响把这一步真相逼近慢慢推回每个人心里。", + "场面像是先静了,可那句没认完的真相还在替这一步真相逼近追账。", + "等人散下去以后,城市夜里迟迟不散的风声才让人知道这一步真相逼近根本没结束。" + ], + "repeat": [ + "动作并不大,可路灯和手机冷光已经说明这一步真相逼近换了味道。", + "谁都还站在原地,只有桌沿和掌心那点迟疑把这一步真相逼近越压越实。", + "表面上没谁失态,可旧巷里不肯散的回响已经替这一步真相逼近露了底。" + ] + }, + "mask_crack": { + "entry": [ + "手机光、纸杯和路灯影子先动了一下,连裂口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可巷口回声与鞋底轻响已经把这一步裂口托到了眼前。" + ], + "pressure": [ + "棚布、玻璃门和杯盖上的脆响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是旧账和那点不肯认的心虚已经把这一步裂口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,桥下风和便利店冷气却只会让这一步裂口更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,钥匙、铁门和路灯反光也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点终于不肯再圆的停顿已经说明这一步裂口再也装不回去了。", + "话还没说尽,城市夜风与回声却先替所有人承认了这一步裂口已经成形。" + ], + "aftermath": [ + "人虽然先收住了,冰柜低鸣、路灯和鞋底回响却还把余波压在原处。", + "真正沉下来的不是声量,而是没说尽的后半句把这一步裂口留得更重了一层。", + "等话音停住以后,旧巷里拖长的静反而把这一步裂口拖得更长。" + ], + "echo": [ + "越到后面,越能听见回声、路灯和铁门余响把这一步裂口慢慢推回每个人心里。", + "场面像是先静了,可那句没认完的真相还在替这一步裂口追账。", + "等人散下去以后,城市夜里迟迟不散的风声才让人知道这一步裂口根本没结束。" + ], + "repeat": [ + "动作并不大,可路灯和手机冷光已经说明这一步裂口换了味道。", + "谁都还站在原地,只有桌沿和掌心那点迟疑把这一步裂口越压越实。", + "表面上没谁失态,可旧巷里不肯散的回响已经替这一步裂口露了底。" + ] + }, + "confession_window": { + "entry": [ + "手机光、纸杯和路灯影子先动了一下,连真话窗口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可巷口回声与鞋底轻响已经把这一步真话窗口托到了眼前。" + ], + "pressure": [ + "棚布、玻璃门和杯盖上的脆响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是旧账和那点不肯认的心虚已经把这一步真话窗口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,桥下风和便利店冷气却只会让这一步真话窗口更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,钥匙、铁门和路灯反光也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点终于不肯再圆的停顿已经说明这一步真话窗口再也装不回去了。", + "话还没说尽,城市夜风与回声却先替所有人承认了这一步真话窗口已经成形。" + ], + "aftermath": [ + "人虽然先收住了,冰柜低鸣、路灯和鞋底回响却还把余波压在原处。", + "真正沉下来的不是声量,而是没说尽的后半句把这一步真话窗口留得更重了一层。", + "等话音停住以后,旧巷里拖长的静反而把这一步真话窗口拖得更长。" + ], + "echo": [ + "越到后面,越能听见回声、路灯和铁门余响把这一步真话窗口慢慢推回每个人心里。", + "场面像是先静了,可那句没认完的真相还在替这一步真话窗口追账。", + "等人散下去以后,城市夜里迟迟不散的风声才让人知道这一步真话窗口根本没结束。" + ], + "repeat": [ + "动作并不大,可路灯和手机冷光已经说明这一步真话窗口换了味道。", + "谁都还站在原地,只有桌沿和掌心那点迟疑把这一步真话窗口越压越实。", + "表面上没谁失态,可旧巷里不肯散的回响已经替这一步真话窗口露了底。" + ] + }, + "karma_ripening": { + "entry": [ + "手机光、纸杯和路灯影子先动了一下,连因果回响都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可巷口回声与鞋底轻响已经把这一步因果回响托到了眼前。" ], "pressure": [ - "杯沿上那一点冷光轻轻一闪,连呼吸都像被逼慢了半拍。" + "棚布、玻璃门和杯盖上的脆响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是旧账和那点不肯认的心虚已经把这一步因果回响压到了最难回避的位置。", + "呼吸和视线都慢了半拍,桥下风和便利店冷气却只会让这一步因果回响更清。" ], "pivot": [ - "风从门缝里钻进来,把场里的沉默一下子吹偏了方向。" + "最轻的一点停顿就把场面拧成了选择,钥匙、铁门和路灯反光也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点终于不肯再圆的停顿已经说明这一步因果回响再也装不回去了。", + "话还没说尽,城市夜风与回声却先替所有人承认了这一步因果回响已经成形。" ], "aftermath": [ - "茶香已经淡了,场里的气却还迟迟不肯散开。" + "人虽然先收住了,冰柜低鸣、路灯和鞋底回响却还把余波压在原处。", + "真正沉下来的不是声量,而是没说尽的后半句把这一步因果回响留得更重了一层。", + "等话音停住以后,旧巷里拖长的静反而把这一步因果回响拖得更长。" ], "echo": [ - "等人散尽以后,连空下来的位置都还像留着刚才那句重话。" + "越到后面,越能听见回声、路灯和铁门余响把这一步因果回响慢慢推回每个人心里。", + "场面像是先静了,可那句没认完的真相还在替这一步因果回响追账。", + "等人散下去以后,城市夜里迟迟不散的风声才让人知道这一步因果回响根本没结束。" ], "repeat": [ - "动作不大,可谁都知道这句真话已经绕不过去了。" + "动作并不大,可路灯和手机冷光已经说明这一步因果回响换了味道。", + "谁都还站在原地,只有桌沿和掌心那点迟疑把这一步因果回响越压越实。", + "表面上没谁失态,可旧巷里不肯散的回响已经替这一步因果回响露了底。" ] } } }, "sensory_grounding": { - "policy_id": "urban_mystery_lotus_lane_default_sensory", + "policy_id": "urban_q03_pack_sensory", "location_slots": { - "generic": { + "旧巷": { "atmosphere": [ - "generic里并不安静,连空气都像压着一句没说完的话。" + "旧巷里潮气很重,墙皮、路灯和风声一起把人心压窄了一圈。", + "旧巷没有真正的安静,电线、门框和脚步的回声都在替旧账留位置。", + "潮湿的墙面把灯影照得发灰,连衣角和呼吸都像被巷子往回推了一下。" ], "detail": [ - "generic里的光线、器物和衣袖摩擦声,都把场里的情绪衬得更清。" + "路灯照着积水、窗格和门框,鞋底蹭过地面带起短促轻响,风把衣角和塑料袋一起吹得发皱", + "墙皮返潮贴着袖口和裤脚,电线影子落进积水与鞋尖边,门边小广告纸被夜风掀得发响", + "路口白光压在玻璃窗和积水上,脚步擦过碎石与门槛,衣摆和发梢都沾着潮气发冷" ], "repeat_detail": [ - "generic里最轻的一点动静,反而把话里的分量又压重了一层。" + "越到后面,旧巷里的积水、灯影和门框回声越像把那句旧话一遍遍弹回来。", + "等脚步声散远了,旧巷里的潮气、白光和纸页轻响反而把情绪压得更实。", + "旧巷先安静下来,可真正不肯散的是灯影、回声和衣角上的那层凉意。" + ] + }, + "便利店门口": { + "atmosphere": [ + "便利店门口的白光太直,门帘、冰柜和玻璃反光都把迟疑照得无处可躲。", + "冷气从便利店门缝里直直扑出来,连塑料门帘和脚边水印都显得太近。", + "便利店门口亮得过分,玻璃门、电箱和路边湿气一起把谎话照得发白。" + ], + "detail": [ + "冰柜低鸣贴着耳边过去,塑料门帘轻轻一摆扫到衣袖和发梢,白光落在纸杯与玻璃门边", + "收银台灯影从玻璃门折到鞋尖,冷气吹过纸杯盖和衣角,门边广告牌在风里轻轻发颤", + "便利店白光照着玻璃、门帘和地上水印,冰柜声贴着袖口过去,纸杯边的水汽把指尖都沾得更凉" + ], + "repeat_detail": [ + "门口那点白光不动声色,却把玻璃、门帘和纸杯上的凉意照得越来越硬。", + "等冷气再扑出来时,便利店门口的灯、玻璃和脚边水印反而把后半句留得更久。", + "越到后面,门口最轻的一点冰柜低鸣都像在替旧账续上一笔。" + ] + }, + "天桥下": { + "atmosphere": [ + "天桥下风声空荡,钢梁、灰尘和远处车灯都像会把一句话重新弹回来。", + "桥下的阴影很重,风从钢梁底下卷过去时,连站位都显得没法糊弄。", + "天桥下没有真正的静,桥身回响、车流噪音和脚边灰都在替这句话找回声。" + ], + "detail": [ + "钢梁阴影压在鞋尖和衣摆边,车灯从桥缝里掠过玻璃与积灰,风把棚布和发梢一起吹得发响", + "桥洞里回声贴着袖口过去,远处车灯照到灰地和鞋边,风从钢梁底下掀起纸屑与衣角", + "桥下噪音压着脚边灰和路灯影子,风顺着钢梁扫过衣摆与发梢,鞋底一动就把回声踩得更清" + ], + "repeat_detail": [ + "越往后,桥下的风、钢梁和车灯回响越像把每个人没说完的话放大。", + "等车声远下去以后,天桥下的灰、风和鞋底轻响反而把那点亏欠留得更久。", + "桥下先空下来,可真正追上来的还是回声、灯影和那句迟早要认的真话。" + ] + }, + "旧档案室": { + "atmosphere": [ + "旧档案室里纸灰和霉味很沉,灯管、铁柜和门缝风一起把真相照得发冷。", + "档案室并不大,纸页、锁扣和灰尘却把每个人的退路都堵得更窄。", + "灯管在旧档案室里嗡嗡作响,连铁柜影子和卷宗边都像带着一层没翻完的旧账。" + ], + "detail": [ + "灯管白光压在铁柜、卷宗和纸边上,门缝风吹起衣袖与灰屑,指尖擦过封条带出细小纸响", + "铁柜冷影落到鞋边和袖口,卷宗边角翘在灯下与桌沿边,门缝风把灰和纸页一起推开", + "档案室白光照着铁柜、卷宗和门框,纸页摩擦过指腹与桌边,灰屑和风一起擦过衣摆" + ], + "repeat_detail": [ + "越到后面,档案室里的纸页、铁柜和门缝风越像把那笔旧账一层层翻回来。", + "等卷宗重新合上以后,灯管、灰屑和桌沿上的冷意反而把那句真相留得更硬。", + "档案室先静下来,可真正不肯散的是纸响、白光和封条边那层凉意。" + ] + }, + "天台": { + "atmosphere": [ + "天台风太直,铁门、护栏和城市灯火一起把人心口吹得发空。", + "天台上没有遮掩,风、霓虹和脚边水迹都像在逼人别再往后退。", + "铁门后的风贴着天台一路扫过去,连护栏、衣摆和呼吸都显得没法收住。" + ], + "detail": [ + "霓虹落在护栏、铁门和积水边,风吹起衣摆与发梢,鞋底擦过水迹发出短促轻响", + "天台风卷着霓虹影子掠过护栏和袖口,铁门轻轻撞回门框,脚边水迹把灯色映得很碎", + "护栏冷光压在鞋尖和衣摆上,铁门回响贴着耳边过去,风把发梢和天台角落的纸屑一起带起来" + ], + "repeat_detail": [ + "等风再从天台卷回来时,护栏、铁门和霓虹碎影反而把那句真话推得更近。", + "越到后面,天台上的水迹、风声和门响越把没认完的话拖得更长。", + "天台看着空,可真正不肯散的是霓虹、回声和衣角上的那层凉意。" ] } }, "generic_slots": { "atmosphere": [ - "场里并不安静,连空气都像在替谁压住一口没说完的话。" + "这座城的夜里没有真正的安静,连风、灯和回声都在替谁记着旧账。", + "空气里混着白光、潮气和噪音,像谁都别想把那句真话干干净净地绕过去。", + "夜色贴得很近,连门影、路灯和呼吸都在逼人承认这事不会自己散掉。" ], "detail": [ - "细小的声响和光线变化,把场里的情绪压得更清了一层。" + "白光、玻璃、门框和衣袖一起发冷,风又从鞋边和纸页上掠过去", + "路灯影子压在杯沿、玻璃和鞋尖上,门边冷气把发梢和衣摆一起吹起", + "铁门、积水、纸页和袖口都在风里轻轻发响,回声顺着墙角拖得更长" ], "repeat_detail": [ - "越到后面,越能听见那些没说尽的话慢慢回身。" + "等沉默拉长以后,连风、灯、玻璃和脚步回声都像在替旧账追账。", + "越到后面,城市夜里最轻的一点白光和风声反而把情绪照得更硬。", + "场面看似静了,真正不肯散的是灯影、噪音和那句还没认完的话。" ] } }, "scene_realization": { - "contract_id": "urban_mystery_lotus_lane_scene_realizer", - "dialogue_policy_id": "urban_mystery_lotus_lane_dialogue", - "default_voice_profile_id": "default", - "default_cadence_id": "default", - "default_pressure_style_id": "default", - "default_emotion_action_policy_id": "default", - "default_sensory_policy_id": "default", - "narrative_style_pack_id": "default", - "scene_openings": {}, - "scene_hooks": {}, + "contract_id": "urban_q03_pack_scene_realization", + "scene_openings": { + "false_peace": [ + "旧巷与天桥下里的白光、霓虹和玻璃反光先压下来,像连这一步表面平静都被提前照到了明处。", + "真正先逼近的不是答案,而是真相、亏欠和说不出口的保护;这一步表面平静只让它再也没法被带过去。", + "潮气和回声里先变的不是声量,而是那层谁都不肯先认的表面平静忽然有了形。" + ], + "truth_trial": [ + "旧巷与天桥下里的白光、霓虹和玻璃反光先压下来,像连这一步真相逼近都被提前照到了明处。", + "真正先逼近的不是答案,而是真相、亏欠和说不出口的保护;这一步真相逼近只让它再也没法被带过去。", + "潮气和回声里先变的不是声量,而是那层谁都不肯先认的真相逼近忽然有了形。" + ], + "mask_crack": [ + "旧巷与天桥下里的白光、霓虹和玻璃反光先压下来,像连这一步裂口都被提前照到了明处。", + "真正先逼近的不是答案,而是真相、亏欠和说不出口的保护;这一步裂口只让它再也没法被带过去。", + "潮气和回声里先变的不是声量,而是那层谁都不肯先认的裂口忽然有了形。" + ], + "confession_window": [ + "旧巷与天桥下里的白光、霓虹和玻璃反光先压下来,像连这一步真话窗口都被提前照到了明处。", + "真正先逼近的不是答案,而是真相、亏欠和说不出口的保护;这一步真话窗口只让它再也没法被带过去。", + "潮气和回声里先变的不是声量,而是那层谁都不肯先认的真话窗口忽然有了形。" + ], + "karma_ripening": [ + "旧巷与天桥下里的白光、霓虹和玻璃反光先压下来,像连这一步因果回响都被提前照到了明处。", + "真正先逼近的不是答案,而是真相、亏欠和说不出口的保护;这一步因果回响只让它再也没法被带过去。", + "潮气和回声里先变的不是声量,而是那层谁都不肯先认的因果回响忽然有了形。" + ] + }, + "scene_hooks": { + "false_peace": [ + "这一步表面平静先停在这里,可真正要追上来的,是没有说尽的旧账与回放。", + "话虽然先落了地,下一次见面时还得继续认的那半句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步表面平静轻轻带过去的那边。" + ], + "truth_trial": [ + "这一步真相逼近先停在这里,可真正要追上来的,是没有说尽的旧账与回放。", + "话虽然先落了地,下一次见面时还得继续认的那半句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步真相逼近轻轻带过去的那边。" + ], + "mask_crack": [ + "这一步裂口先停在这里,可真正要追上来的,是没有说尽的旧账与回放。", + "话虽然先落了地,下一次见面时还得继续认的那半句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步裂口轻轻带过去的那边。" + ], + "confession_window": [ + "这一步真话窗口先停在这里,可真正要追上来的,是没有说尽的旧账与回放。", + "话虽然先落了地,下一次见面时还得继续认的那半句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步真话窗口轻轻带过去的那边。" + ], + "karma_ripening": [ + "这一步因果回响先停在这里,可真正要追上来的,是没有说尽的旧账与回放。", + "话虽然先落了地,下一次见面时还得继续认的那半句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步因果回响轻轻带过去的那边。" + ] + }, "scene_pressures": {} } } diff --git a/examples/worldpacks/genre_pack_xianxia.json b/examples/worldpacks/genre_pack_xianxia.json index 741c0ed..9dc81a5 100644 --- a/examples/worldpacks/genre_pack_xianxia.json +++ b/examples/worldpacks/genre_pack_xianxia.json @@ -29,7 +29,9 @@ "locations": [ "偏殿", "石阶", - "山门" + "山门", + "镜湖", + "丹房" ] }, "characters": [ @@ -140,19 +142,121 @@ "scene_blueprints": [ { "scene_id": "lamp_awakens", - "scene_function": "setup", + "scene_function": "false_peace", "phase_support": [ - "setup" + "setup", + "early_rising" ], "required_roles": [ "lead", "counterpart" ], "beats_template": [ - "古灯异动", - "旧誓回响", - "不祥预兆", - "命题落下" + "偏殿古灯忽然自燃,灯芯里藏着的誓纹先把两人的旧事照了出来", + "沈照抬手想熄灯,却因为护住叶青竹而把真正的慌张露得更清", + "灯焰顺着石阶和衣摆一路追到门边,不祥预兆逼得谁都无法再装作没听见", + "命题真正落下时,他得先承认自己究竟要护大道还是要护眼前这个人" + ], + "wound_triggers": [ + "被命数决定去留" + ], + "vow_tests": [ + "protect_qingzhu_without_lying" + ], + "continuation_blueprints": [ + { + "blueprint_id": "lamp_awakens::confession_window", + "scene_function": "confession_window", + "location": "偏殿", + "title": "照骨灯前那句一直绕着走的旧誓终于被逼到明处", + "summary": "偏殿灯火不肯灭下去,逼得沈照第一次承认这盏灯追上来的不只是天命,还有他一直没敢当着叶青竹认下的那句旧誓。", + "tags": [ + "truth", + "love", + "xianxia" + ], + "agency_affordances": [ + "truth", + "confession", + "continue_story" + ], + "promises_close": [ + "lamp_awakens__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "midpoint", + "crisis", + "climax", + "aftermath" + ], + "tension_delta": 0.12 + }, + { + "blueprint_id": "lamp_awakens::truth_trial", + "scene_function": "truth_trial", + "location": "山门", + "title": "照骨灯照出来的那句真话终于换成了正面逼问", + "summary": "山门风起时,叶青竹不再陪沈照绕路,而是把那句“你护的是大道还是护我”逼到最难回避的位置。", + "tags": [ + "truth", + "destiny", + "love" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "lamp_awakens__promise" + ], + "duty_allowlist": [ + "advance_plot", + "deliver_climax", + "resolve_promise" + ], + "phase_allowlist": [ + "midpoint", + "crisis", + "climax" + ], + "tension_delta": 0.14 + }, + { + "blueprint_id": "lamp_awakens::karma_ripening", + "scene_function": "karma_ripening", + "location": "镜湖", + "title": "偏殿古灯埋下的因果终于在镜湖边开始回潮", + "summary": "镜湖的倒影把偏殿那夜压下去的波纹重新推回眼前,逼得两人承认那一盏灯已经把他们的命数缠到了一起。", + "tags": [ + "destiny", + "truth", + "xianxia" + ], + "agency_affordances": [ + "memory", + "truth", + "continue_story" + ], + "promises_close": [ + "lamp_awakens__promise" + ], + "duty_allowlist": [ + "expand_world", + "resolve_promise", + "pace_breath" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ], + "tension_delta": 0.1 + } ] }, { @@ -160,6 +264,7 @@ "scene_function": "temptation", "phase_support": [ "early_rising", + "midpoint", "crisis" ], "required_roles": [ @@ -167,10 +272,366 @@ "counterpart" ], "beats_template": [ - "旧人现身", - "誓言动摇", - "代价显形", - "强行压下" + "旧人携着断剑残信站到山门前,逼得沈照没法再把旧誓当成死物", + "叶青竹替他把最不该问的那句问到明处:若天命和旧誓撞上,你先舍哪一个", + "反噬的灵息顺着剑鞘和伤口一寸寸爬上来,把代价显给两个人看", + "沈照强行压下心魔时,真正压不住的反而是已经出口的半句真话" + ], + "wound_triggers": [ + "先被抛下的人永远记得那一步" + ], + "vow_tests": [ + "choose_qingzhu_over_pure_fate" + ], + "continuation_blueprints": [ + { + "blueprint_id": "vow_trial::truth_trial", + "scene_function": "truth_trial", + "location": "山门", + "title": "山门前那句若天命与旧誓撞上先舍哪一个终于被追到最重", + "summary": "山门风里,叶青竹不再允许沈照把那句最重的话拖去下一次,而是逼他现在就认清自己到底会先舍哪一边。", + "tags": [ + "truth", + "destiny", + "love" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "vow_trial__promise" + ], + "duty_allowlist": [ + "advance_plot", + "deliver_climax", + "resolve_promise" + ], + "phase_allowlist": [ + "midpoint", + "crisis", + "climax" + ], + "tension_delta": 0.15 + }, + { + "blueprint_id": "vow_trial::debt_exchange", + "scene_function": "debt_exchange", + "location": "石阶", + "title": "旧誓欠下的命债终于开始往沈照自己身上结算", + "summary": "石阶上那点反噬没有白落,沈照终于得亲手替自己这些年欠下的旧誓与隐瞒开始偿付。", + "tags": [ + "destiny", + "debt", + "truth" + ], + "agency_affordances": [ + "sacrifice", + "truth", + "continue_story" + ], + "promises_close": [ + "vow_trial__promise" + ], + "duty_allowlist": [ + "resolve_promise", + "advance_plot" + ], + "phase_allowlist": [ + "crisis", + "aftermath" + ], + "tension_delta": 0.13 + }, + { + "blueprint_id": "vow_trial::confession_window", + "scene_function": "confession_window", + "location": "镜湖", + "title": "旧誓动摇以后,终于出现了一个不能再拿大道遮羞的窗口", + "summary": "镜湖边没有人替沈照把场面收回去,反而逼得他第一次把“我其实早就偏向你了”那句心意认出一半。", + "tags": [ + "love", + "truth", + "xianxia" + ], + "agency_affordances": [ + "confession", + "truth", + "continue_story" + ], + "promises_close": [ + "vow_trial__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ], + "tension_delta": 0.1 + } + ] + }, + { + "scene_id": "mirror_confession", + "scene_function": "confession_window", + "phase_support": [ + "midpoint", + "crisis" + ], + "required_roles": [ + "lead", + "counterpart" + ], + "beats_template": [ + "镜湖的倒影先碎了一层,逼得沈照承认古灯异动并不是单纯天意", + "叶青竹把断掉的护符放回他掌心,第一次要求他别再拿大道替两人的旧誓遮羞", + "湖面和灵息一起发颤,那句最难说出口的名字终于被逼到了喉间", + "他说出的真话还只到一半,山门外的风却已经把余波卷向更远的地方" + ], + "wound_triggers": [ + "被命数决定去留" + ], + "vow_tests": [ + "speak_the_old_vow_plainly" + ], + "continuation_blueprints": [ + { + "blueprint_id": "mirror_confession::confession_window", + "scene_function": "confession_window", + "location": "镜湖", + "title": "镜湖边那句没认完的名字终于被沈照自己说全", + "summary": "镜湖里的倒影不再给他留退路,沈照终于把那句一直只敢认一半的旧誓和名字都亲口说全。", + "tags": [ + "love", + "truth", + "xianxia" + ], + "agency_affordances": [ + "truth", + "confession", + "continue_story" + ], + "promises_close": [ + "mirror_confession__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "deliver_climax", + "pace_breath" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ], + "tension_delta": 0.12 + }, + { + "blueprint_id": "mirror_confession::karma_ripening", + "scene_function": "karma_ripening", + "location": "偏殿", + "title": "镜湖边说出口的那半句真话开始带着因果回潮", + "summary": "偏殿里的照骨灯重新亮起时,镜湖边那次没说尽的话已经不再只是心意,而开始变成真正要偿付的因果。", + "tags": [ + "destiny", + "truth", + "xianxia" + ], + "agency_affordances": [ + "memory", + "truth", + "continue_story" + ], + "promises_close": [ + "mirror_confession__promise" + ], + "duty_allowlist": [ + "expand_world", + "resolve_promise" + ], + "phase_allowlist": [ + "aftermath", + "crisis", + "climax" + ], + "tension_delta": 0.11 + }, + { + "blueprint_id": "mirror_confession::truth_trial", + "scene_function": "truth_trial", + "location": "山门", + "title": "镜湖边那句真话没有停住,反而被追成了更正面的逼问", + "summary": "山门外的风把镜湖边那句半认不认的真话吹得更重,逼得两人终于不再让那层心意停在影子里。", + "tags": [ + "truth", + "destiny", + "love" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "mirror_confession__promise" + ], + "duty_allowlist": [ + "advance_plot", + "deliver_climax", + "expand_world" + ], + "phase_allowlist": [ + "climax", + "aftermath" + ], + "tension_delta": 0.13 + } + ] + }, + { + "scene_id": "meridian_crack", + "scene_function": "mask_crack", + "phase_support": [ + "midpoint", + "crisis", + "climax" + ], + "required_roles": [ + "lead", + "counterpart" + ], + "beats_template": [ + "丹房里的旧药香压不住经脉失控,沈照终于露出自己早已快撑不住的裂口", + "叶青竹不再替他圆场,只把那份看穿后的沉默稳稳放在两人之间", + "真气一乱,所有大道与克制都变成了遮不住心意的薄壳", + "等他重新站稳时,已经没人还能把刚才那层失守说成偶然" + ], + "wound_triggers": [ + "靠克制才值得被留下" + ], + "vow_tests": [ + "stop_hiding_inside_restraint" + ] + }, + { + "scene_id": "karma_reckoning", + "scene_function": "karma_ripening", + "phase_support": [ + "crisis", + "climax", + "aftermath" + ], + "required_roles": [ + "lead", + "counterpart" + ], + "beats_template": [ + "那枚被埋进灯座里的旧誓残片终于裂开,把拖欠已久的因果推回眼前", + "沈照必须亲手替叶青竹挡下一道反噬,才知道自己过去的隐瞒已经成了债", + "旧债回潮时,两人的灵息第一次没有朝着相反的方向退开", + "代价暂时被接住了,可下一次再开口时,他们谁都躲不开真正的偿付" + ], + "wound_triggers": [ + "先被抛下的人永远记得那一步" + ], + "vow_tests": [ + "pay_the_old_vow_together" + ], + "continuation_blueprints": [ + { + "blueprint_id": "karma_reckoning::debt_exchange", + "scene_function": "debt_exchange", + "location": "偏殿", + "title": "替她挡下反噬以后,旧债终于开始被真正偿付", + "summary": "偏殿里那道反噬落定之后,沈照再也不能只说自己会扛,而必须把旧誓和隐瞒欠下的债一点点还回去。", + "tags": [ + "debt", + "destiny", + "truth" + ], + "agency_affordances": [ + "sacrifice", + "truth", + "continue_story" + ], + "promises_close": [ + "karma_reckoning__promise", + "vow_trial__promise" + ], + "duty_allowlist": [ + "resolve_promise", + "advance_plot" + ], + "phase_allowlist": [ + "crisis", + "aftermath" + ], + "tension_delta": 0.12 + }, + { + "blueprint_id": "karma_reckoning::truth_trial", + "scene_function": "truth_trial", + "location": "山门", + "title": "反噬之后,两人终于把最难认的那句真相逼到了正面", + "summary": "山门风起时,替她挡下反噬这件事不再只是代价,而成了两人都不能再绕开的那句真相。", + "tags": [ + "truth", + "destiny", + "love" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "karma_reckoning__promise" + ], + "duty_allowlist": [ + "deliver_climax", + "advance_plot", + "expand_world" + ], + "phase_allowlist": [ + "climax", + "aftermath" + ], + "tension_delta": 0.14 + }, + { + "blueprint_id": "karma_reckoning::confession_window", + "scene_function": "confession_window", + "location": "镜湖", + "title": "因果回潮之后,两个人终于得到一次能把旧誓说完整的窗口", + "summary": "镜湖边的风没有替任何人减轻代价,却第一次给了他们一个不用再躲进大道和沉默的窗口。", + "tags": [ + "love", + "truth", + "xianxia" + ], + "agency_affordances": [ + "confession", + "repair", + "continue_story" + ], + "promises_close": [ + "karma_reckoning__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ], + "tension_delta": 0.1 + } ] } ], @@ -205,22 +666,34 @@ "restraint": 0.84, "social_rank_awareness": 0.72, "opening_style": [ - "旧誓若真要追上来,也该先由我自己接住。" + "旧誓若真要追上来,也该先由我自己接住。", + "偏殿这盏灯既然先亮了,我就不该还拿沉默把你隔在外面。", + "天命若真要落下,也该先落到我身上,而不是继续逼你替我受。" ], "pressure_style": [ - "你若非逼我开口,我也不愿让这层反噬先落到你身上。" + "你若非逼我开口,我也不愿让这层反噬先落到你身上。", + "我不是不认,只是不想让你替我去接那道本该冲我来的天罚。", + "再往后退一步,灯里这道旧誓就会先把你拖进去。" ], "pivot_style": [ - "大道我没有忘,只是这一次我也不能再把你留在身后。" + "大道我没有忘,只是这一次我也不能再把你留在身后。", + "若连你都要我继续装稳,那我这些年守住的就只剩一层空壳了。", + "真要在天命和你之间认一边,我至少该先承认自己已经偏向了谁。" ], "aftermath_style": [ - "这一句先落在这里,后面的天罚我自己去领。" + "这一句先落在这里,后面的天罚我自己去领。", + "话既然出了口,我就不想再把代价推回你那边。", + "旧誓先算在我身上,回头你若还肯问,我再把剩下的都认完。" ], "echo_style": [ - "等下一次再见时,我不会只带着一身沉默回来。" + "等下一次再见时,我不会只带着一身沉默回来。", + "若我还能回来见你,带回来的就不该再只是半句真话。", + "山门外的风若再把我们逼到一处,我不会再把那句欠你的话咽回去。" ], "signature_replies": [ - "你先别替我断,我还想亲手把这句誓补完整。" + "你先别替我断,我还想亲手把这句誓补完整。", + "这一步先算我认下,你别再替我把后半句压回灯灰里。", + "真要补旧誓,也该由我当着你的面把它说全。" ] }, "ye_qingzhu": { @@ -231,22 +704,34 @@ "restraint": 0.46, "social_rank_awareness": 0.58, "opening_style": [ - "誓既然是你亲口许下的,就别总拿大道当成回避我的借口。" + "誓既然是你亲口许下的,就别总拿大道当成回避我的借口。", + "你若又想把我挡在灯外,那我今天就偏要把这句问到底。", + "我不是来陪你讲道理的,我是来问你这道旧伤到底认不认。" ], "pressure_style": [ - "我来不是求你回头,是要你承认这道伤到底落在谁身上。" + "我来不是求你回头,是要你承认这道伤到底落在谁身上。", + "你若还想一个人把反噬扛完,那我就先把你自欺的那层壳掀开。", + "别再拿护我当借口,你真正舍不得认的是你自己早就动摇了。" ], "pivot_style": [ - "你若总想一个人把天命背完,那我偏要把这句话逼到明处。" + "你若总想一个人把天命背完,那我偏要把这句话逼到明处。", + "你不是不会选,你只是怕一认下来,就再也装不回那个干净的自己。", + "真要坏,就让它坏在今天,别再让我守着你没说完的旧誓继续等。" ], "aftermath_style": [ - "我先不替你收场,等你自己把这句旧誓认清。" + "我先不替你收场,等你自己把这句旧誓认清。", + "这句话先压在这里,你回头总得自己把它捡起来。", + "我可以先退半步,但你别指望这件事会跟着一起散掉。" ], "echo_style": [ - "下次再见时,你最好带着真话,不是带着更圆的道理。" + "下次再见时,你最好带着真话,不是带着更圆的道理。", + "等山门风再起的时候,我不会再替你把那句真心听成大道。", + "你若还肯来,就带着完整的答案,不要再拿沉默试我。" ], "signature_replies": [ - "我不是来听你讲大道的,我是来问你到底还肯不肯看我。" + "我不是来听你讲大道的,我是来问你到底还肯不肯看我。", + "你若真想护我,就先别再把我隔在你的誓外。", + "这一回我不替你退,你也别想再把我留在门外。" ] } }, @@ -256,36 +741,56 @@ "reaction_tempo": "measured", "reaction_lines": { "entry": [ - "他先垂了垂眼,像把翻起来的灵息又一点点按回骨血里。" + "他先垂了垂眼,像把翻起来的灵息又一点点按回骨血里。", + "灯焰映到他睫下时,他先把那口气稳住,像怕一抬眼就会把旧誓全露出来。", + "他手指拢住袖口,骨节很轻地绷了一下,像先替自己把那点失守压回去。" ], "pressure": [ - "袍袖下的手指轻轻一蜷,连呼吸都像先被他自己截断了一截。" + "袍袖下的手指轻轻一蜷,连呼吸都像先被他自己截断了一截。", + "檐角的风压过来时,他先把肩背收得更直,像怕一松就真退了。", + "他没有立刻回话,只让灵息在经脉里滞了一瞬,那点停顿比解释更像认错。" ], "pivot": [ - "他这才抬眼,眼底那点动摇没有散尽,语气却已经不肯再退。" + "他这才抬眼,眼底那点动摇没有散尽,语气却已经不肯再退。", + "他沿着灯影看了她一眼,像终于决定让那句真话落地,而不是继续让它悬着。", + "等指节从剑柄上松开时,他反而更像把真正的站位认了下来。" ], "aftermath": [ - "收声时他反倒更静,静得像把更重的代价先压回自己身上。" + "收声时他反倒更静,静得像把更重的代价先压回自己身上。", + "他说完以后没再补第二句,只把那点要追上来的后果先往自己身前拦住。", + "灯火又跳了一下,他却比刚才更稳,稳得像已经准备好替这句话担账。" ], "echo": [ - "他不再追着解释,可那层未尽之意仍在周身灵息里发紧。" + "他不再追着解释,可那层未尽之意仍在周身灵息里发紧。", + "他先转开了目光,山门风却像替他把那句没说完的话追得更近。", + "等檐铃再轻轻一响时,他整个人都像还停在刚才那半句真话里。" ] }, "reply_lines": { "entry": [ - "你既然追到这里,我便不想再把这句话藏回去。" + "你既然追到这里,我便不想再把这句话藏回去。", + "灯都已经替我们照到了这里,我再装没听见也没有用了。", + "你要是真肯问,我至少该先把这一层认给你看。" ], "pressure": [ - "我不是不肯认,只是不想让反噬先顺着你落下来。" + "我不是不肯认,只是不想让反噬先顺着你落下来。", + "你若还站在我这边,我就更不能让这道灾先落到你身上。", + "我怕的不是承认,是一认下来就真把你拖进我该受的那道命里。" ], "pivot": [ - "若连这一句我都不敢承,后面的天命我也担不起。" + "若连这一句我都不敢承,后面的天命我也担不起。", + "真要走到非选不可的时候,我也不能再把你丢回身后。", + "若我再装稳,那才是真的辜负了我们当年那句誓。" ], "aftermath": [ - "这层代价先记在我身上,你不必替我接。" + "这层代价先记在我身上,你不必替我接。", + "后面的反噬由我来认,你别再替我往回挡。", + "这句既然已经落地,就先算我欠你的那笔账开始还了。" ], "echo": [ - "等我再回来时,我会把那句欠你的话一并补完。" + "等我再回来时,我会把那句欠你的话一并补完。", + "下一次若还有机会站到你面前,我不会再只给你半句。", + "等风声再追上来时,我会带着完整的答案回来见你。" ] } }, @@ -294,36 +799,56 @@ "reaction_tempo": "tight", "reaction_lines": { "entry": [ - "她并未立刻近前,只把目光钉在他脸上,像先把旧伤一寸寸照亮。" + "她并未立刻近前,只把目光钉在他脸上,像先把旧伤一寸寸照亮。", + "她站得并不更近,可那道视线先把他能躲的地方都一一封死了。", + "她先把剑穗压进掌心,像在提醒自己别再替他的沉默找借口。" ], "pressure": [ - "她指尖搭在剑穗上,没真碰响,那点停顿反而更像逼问。" + "她指尖搭在剑穗上,没真碰响,那点停顿反而更像逼问。", + "她把呼吸压得很轻,偏偏让每个字都显得比刚才更锋利。", + "她没往前迈,可袖角一动,像已经替他把最后那点退路截断了。" ], "pivot": [ - "她这才开口,语气清冷,却把最不肯听的那句推到了面前。" + "她这才开口,语气清冷,却把最不肯听的那句推到了面前。", + "她抬眼时并不见怒,可那点失望反而把话压到了更重的地方。", + "她没有替他留体面,只把真相稳稳地放在两人之间,等他自己来认。" ], "aftermath": [ - "她先收住了势,可那点不肯退的锋芒还停在原处。" + "她先收住了势,可那点不肯退的锋芒还停在原处。", + "她没再追问,偏殿里的静却替她把余下那半句挂得更久。", + "她把手从剑穗上松开,却没有把那点逼问一并撤走。" ], "echo": [ - "她不再多说,可檐下那声铃响倒像替她把余话追了回来。" + "她不再多说,可檐下那声铃响倒像替她把余话追了回来。", + "她先转身半步,风却把她那句没说完的话仍旧留在原地。", + "等灯影落到她衣摆边时,谁都知道她还在等一个真正的答案。" ] }, "reply_lines": { "entry": [ - "你若还肯认这层旧誓,就别再拿沉默替自己挡。" + "你若还肯认这层旧誓,就别再拿沉默替自己挡。", + "我既然追到灯下,就不是来听你把这句话继续藏起来的。", + "你若真把我算在心上,就先别再把这层旧事说成过去。" ], "pressure": [ - "我不怕反噬,只怕你又把我留在你那套大道之外。" + "我不怕反噬,只怕你又把我留在你那套大道之外。", + "你要真想护我,就别总拿把我推开的法子来成全自己。", + "我怕的从来不是难听,是你又要把我隔在你肯认下的那一边之外。" ], "pivot": [ - "你若总往后退,我就只好把这句真话追到山门外。" + "你若总往后退,我就只好把这句真话追到山门外。", + "你若再把大道举得比我更前,那就别怪我今天非逼你认清自己。", + "我宁可现在就把伤口挑开,也不想继续守着你讲得更圆的借口。" ], "aftermath": [ - "先把这句放下,回头你还是得自己来接。" + "先把这句放下,回头你还是得自己来接。", + "我先不替你收场,你总得学会自己把这句话捡起来。", + "这件事先停在这里,可不是因为它能过去,而是因为你还欠着后半句。" ], "echo": [ - "下一回再见,我要听的是你的真心,不是更漂亮的道理。" + "下一回再见,我要听的是你的真心,不是更漂亮的道理。", + "等你真肯来时,别再只带着能哄人的那一套话。", + "山门风再起的时候,你最好带着答案,不是带着新的沉默。" ] } } @@ -331,88 +856,245 @@ "pressure_response_styles": { "shen_zhao": { "style_id": "lead", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "先把灵息按稳,再把最难认的那句慢慢送出去", + "when_cornered": "宁可自己扛反噬,也不肯先把人推出去", + "when_softening": "语气微松,却把代价先揽回自己身前", + "when_deflecting": "总想拿大道替真心挪开半寸" + }, + "ye_qingzhu": { + "style_id": "counterpart", + "under_pressure": "语气越轻,越像把真话一寸寸逼到明处", + "when_cornered": "不替任何人留体面,只把最重的那句稳稳放下", + "when_softening": "先收锋芒,但边界一步也不往回退", + "when_deflecting": "看穿对方想躲的地方,再追着那句真话不放" } }, "emotion_action_policies": { "default": { - "policy_id": "xianxia_default_action", + "policy_id": "xianxia_q03_pack_action", "action_map": { "false_peace": { "entry": [ - "袍袖扫过石阶,灵息只乱了一瞬,原本压住的气机便跟着起了波纹。" + "灯焰、剑穗和袖角先动了一下,连表面平静都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是灵息和目光在这一瞬一起收住了。", + "谁都没真往前走,可石阶上的风声与檐铃回响已经把这一步表面平静托到了眼前。" ], "pressure": [ - "偏殿檐角的风卷下来,连铃索都只轻轻一晃,谁也不肯先退。" + "铃索、灯灰和剑柄上的细响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是经脉里的反噬与灯下影子已经把这一步表面平静压到了最难回避的位置。", + "呼吸和视线都慢了半拍,山门风和偏殿冷意却只会让这一步表面平静更清。" ], "pivot": [ - "他指节在剑柄上一松一紧,那点迟疑比任何一句话都更像裂口。" + "最轻的一点停顿就把场面拧成了选择,剑穗、护符和灯影也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点灵息失衡后的回响已经说明这一步表面平静再也装不回去了。", + "话还没说尽,檐角风与灯火轻震却先替所有人承认了这一步表面平静已经成形。" ], "aftermath": [ - "表面上无人失态,可散开时每个人周身的气机都比来时更沉。" + "人虽然先收住了,灯灰、衣摆和石阶冷意却还把余波压在原处。", + "真正沉下来的不是声量,而是没散尽的灵息余震把这一步表面平静留得更重了一层。", + "等话音停住以后,偏殿里拖长的静反而把这一步表面平静拖得更长。" ], "echo": [ - "等灯焰再跳一下时,那些没说尽的旧誓便像在骨血里缓缓回响。" + "越到后面,越能听见檐铃、灯焰和山门风把这一步表面平静慢慢推回每个人心里。", + "场面像是先静了,可骨血里回响的旧誓还在替这一步表面平静追账。", + "等人散下去以后,偏殿里重新压低的风声才让人知道这一步表面平静根本没结束。" ], "repeat": [ - "动作极轻,可谁都知道这层平静已经压不住真正的反噬。" + "动作并不大,可灯影和灵息已经说明这一步表面平静换了味道。", + "谁都还站在原地,只有石阶边那点迟疑把这一步表面平静越压越实。", + "表面上没谁失态,可山门风里不肯散掉的旧誓已经替这一步表面平静露了底。" ] }, "temptation": { "entry": [ - "先动的不是人,而是照骨灯里那一点忽明忽暗的灵火。" + "灯焰、剑穗和袖角先动了一下,连试探都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是灵息和目光在这一瞬一起收住了。", + "谁都没真往前走,可石阶上的风声与檐铃回响已经把这一步试探托到了眼前。" ], "pressure": [ - "灯焰一偏,连影子都像替人把那点执念照得更清。" + "铃索、灯灰和剑柄上的细响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是经脉里的反噬与灯下影子已经把这一步试探压到了最难回避的位置。", + "呼吸和视线都慢了半拍,山门风和偏殿冷意却只会让这一步试探更清。" ], "pivot": [ - "两股气机在半空里轻轻一撞,谁都知道这回再退只会更痛。" + "最轻的一点停顿就把场面拧成了选择,剑穗、护符和灯影也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点灵息失衡后的回响已经说明这一步试探再也装不回去了。", + "话还没说尽,檐角风与灯火轻震却先替所有人承认了这一步试探已经成形。" ], "aftermath": [ - "风过石阶,余震未散,像旧誓还在等一个人先认输。" + "人虽然先收住了,灯灰、衣摆和石阶冷意却还把余波压在原处。", + "真正沉下来的不是声量,而是没散尽的灵息余震把这一步试探留得更重了一层。", + "等话音停住以后,偏殿里拖长的静反而把这一步试探拖得更长。" ], "echo": [ - "越到后面,越能听见那点心魔在沉默里一点点抬头。" + "越到后面,越能听见檐铃、灯焰和山门风把这一步试探慢慢推回每个人心里。", + "场面像是先静了,可骨血里回响的旧誓还在替这一步试探追账。", + "等人散下去以后,偏殿里重新压低的风声才让人知道这一步试探根本没结束。" ], "repeat": [ - "没人真出手,可那点诱惑已经顺着灵息渗进了骨头里。" + "动作并不大,可灯影和灵息已经说明这一步试探换了味道。", + "谁都还站在原地,只有石阶边那点迟疑把这一步试探越压越实。", + "表面上没谁失态,可山门风里不肯散掉的旧誓已经替这一步试探露了底。" + ] + }, + "confession_window": { + "entry": [ + "镜湖边先静下来的不是风,而是那句终于不肯再绕开的名字被推到了唇边。", + "护符重新落进掌心的一瞬,两个人都知道这次不是再试探,而是要真把旧誓说出来。", + "最先绷紧的不是声量,而是沈照终于明白这回再讲大道就只会更像遮羞。" + ], + "pressure": [ + "镜湖风把衣摆和发梢一起掀起,逼得每个字都像带着再也收不回去的重量。", + "她不往前迈,只把断掉的护符放回他掌心,像在逼他自己把后半句认完。", + "他把呼吸压低,反倒让那句最不敢说的真心显得更像已经被照穿。" + ], + "pivot": [ + "真正拧紧场面的不是重话,而是那句一直只敢认一半的名字终于被说到了明处。", + "最轻的一点停顿过后,原本还能躲在湖光里的心意忽然变成了必须承认的真话。", + "护符轻轻碰到指节时,连镜湖风都像替这一步告白落了锤。" + ], + "aftermath": [ + "话停下来以后,留在镜湖边的不是空白,而是已经不能再往回收的名字。", + "她先收了声,可那种终于有人把旧誓说到明处的静反而比刚才更重。", + "镜湖边的风没有替谁把事吹散,反而把刚认下的那句真心压得更实。" + ], + "echo": [ + "越到后面,越能听见那句终于被认下来的名字沿着湖风继续追上来。", + "人虽然先退开了,可湖光和护符轻响还在替那句真心把后果往前推。", + "等风再掠过镜湖时,真正没法装没发生的,已经是那句被说完整的旧誓。" + ], + "repeat": [ + "这次不是又提旧誓,而是旧誓真的开始有人当着对方的面认了。", + "动作不大,可谁都知道这一步已经从试探变成了告白。", + "护符那点轻响过去以后,这句真心就再也不只是悬着。" ] }, "mask_crack": { "entry": [ - "他嘴上仍压得平稳,袖中的真气却先露了一丝乱。" + "灯焰、剑穗和袖角先动了一下,连裂口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是灵息和目光在这一瞬一起收住了。", + "谁都没真往前走,可石阶上的风声与檐铃回响已经把这一步裂口托到了眼前。" ], "pressure": [ - "她不再逼近,只把那一点看穿了的沉默稳稳放在两人之间。" + "铃索、灯灰和剑柄上的细响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是经脉里的反噬与灯下影子已经把这一步裂口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,山门风和偏殿冷意却只会让这一步裂口更清。" ], "pivot": [ - "那层大道与私心的壳终于裂了一缝,谁都装不回去了。" + "最轻的一点停顿就把场面拧成了选择,剑穗、护符和灯影也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点灵息失衡后的回响已经说明这一步裂口再也装不回去了。", + "话还没说尽,檐角风与灯火轻震却先替所有人承认了这一步裂口已经成形。" ], "aftermath": [ - "场面没有炸开,真正的失守却已经在心里落了地。" + "人虽然先收住了,灯灰、衣摆和石阶冷意却还把余波压在原处。", + "真正沉下来的不是声量,而是没散尽的灵息余震把这一步裂口留得更重了一层。", + "等话音停住以后,偏殿里拖长的静反而把这一步裂口拖得更长。" ], "echo": [ - "等下一次再开口时,谁都回不到还能强讲大道的那一边。" + "越到后面,越能听见檐铃、灯焰和山门风把这一步裂口慢慢推回每个人心里。", + "场面像是先静了,可骨血里回响的旧誓还在替这一步裂口追账。", + "等人散下去以后,偏殿里重新压低的风声才让人知道这一步裂口根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和灵息已经说明这一步裂口换了味道。", + "谁都还站在原地,只有石阶边那点迟疑把这一步裂口越压越实。", + "表面上没谁失态,可山门风里不肯散掉的旧誓已经替这一步裂口露了底。" ] }, - "confession_window": { + "karma_ripening": { "entry": [ - "偏殿忽然静极,连灯焰的轻响都像替这句真话腾出了位置。" + "灯焰、剑穗和袖角先动了一下,连因果回响都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是灵息和目光在这一瞬一起收住了。", + "谁都没真往前走,可石阶上的风声与檐铃回响已经把这一步因果回响托到了眼前。" ], "pressure": [ - "他把呼吸压到最稳,反而更显得那句想说的话早已顶到喉间。" + "铃索、灯灰和剑柄上的细响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是经脉里的反噬与灯下影子已经把这一步因果回响压到了最难回避的位置。", + "呼吸和视线都慢了半拍,山门风和偏殿冷意却只会让这一步因果回响更清。" ], "pivot": [ - "她没有替他收拾场面,只把那点空白留成了最逼人的台阶。" + "最轻的一点停顿就把场面拧成了选择,剑穗、护符和灯影也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点灵息失衡后的回响已经说明这一步因果回响再也装不回去了。", + "话还没说尽,檐角风与灯火轻震却先替所有人承认了这一步因果回响已经成形。" ], "aftermath": [ - "真话一落下来,连周围的灵息都像跟着轻了一瞬。" + "人虽然先收住了,灯灰、衣摆和石阶冷意却还把余波压在原处。", + "真正沉下来的不是声量,而是没散尽的灵息余震把这一步因果回响留得更重了一层。", + "等话音停住以后,偏殿里拖长的静反而把这一步因果回响拖得更长。" ], "echo": [ - "这一回先说到这里,可真正的取舍还要到下一次见面时才算数。" + "越到后面,越能听见檐铃、灯焰和山门风把这一步因果回响慢慢推回每个人心里。", + "场面像是先静了,可骨血里回响的旧誓还在替这一步因果回响追账。", + "等人散下去以后,偏殿里重新压低的风声才让人知道这一步因果回响根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和灵息已经说明这一步因果回响换了味道。", + "谁都还站在原地,只有石阶边那点迟疑把这一步因果回响越压越实。", + "表面上没谁失态,可山门风里不肯散掉的旧誓已经替这一步因果回响露了底。" + ] + }, + "truth_trial": { + "entry": [ + "山门风先压过檐铃和衣摆,像所有绕路都已经被这句逼问一寸寸封死。", + "她并不抬声,只把那句“你护的是谁”稳稳放下,连风都像跟着静了一瞬。", + "最先发紧的不是剑柄,而是沈照终于知道这一回再答半句也没有用了。" + ], + "pressure": [ + "铃索和灯灰一起发响,逼得每个字都像带着要人立刻认账的分量。", + "他把呼吸压低,反倒让那层不敢认的偏向更像已经被照穿。", + "她不往前迈,只把目光稳稳压住,像在等他亲手把最后那层借口撕开。" + ], + "pivot": [ + "真正拧紧场面的不是怒气,而是沈照终于把那句偏向哪一边的话顶到了明处。", + "最轻的一点停顿过后,原本还能周旋的旧誓忽然变成了必须选边的真相。", + "剑穗轻轻一撞,连山门风都像替这一步真相逼近落了锤。" + ], + "aftermath": [ + "话停下来以后,留在山门前的不是空白,而是已经不能再往回缩的答案。", + "她先收了声,可那种终于有人把真话顶出来的静反而比刚才更重。", + "山门风没替谁把事吹散,反而把刚认下的那层真相压得更实。" + ], + "echo": [ + "越到后面,越能听见那句终于被逼出来的真相沿着风声继续追账。", + "人虽然先退开了,可檐铃和风声还在替那句答案把后果往前推。", + "等风再掠过去时,真正没法装没发生的,已经是那句被认下来的真话。" + ], + "repeat": [ + "这次不是又问了一遍,而是那句真相真的开始有人认了。", + "动作不大,可谁都知道这一步已经从试探变成了摊牌。", + "檐下那点轻响过去以后,这句真话就再也不只是悬着。" + ] + }, + "debt_exchange": { + "entry": [ + "偏殿里的灯火压低一层,像旧誓欠下的那笔债终于开始有人来还。", + "他把那口气压住时,掌心和袖口先绷了一下,像在替那笔命债落下第一枚钉。", + "她没有立刻追问,只把那点已经说不回去的代价稳稳放在两人之间。" + ], + "pressure": [ + "灯灰和铃声一起发紧,逼得每个字都像带着真正偿付的重量。", + "他把话压低,反倒让那层该还的代价显得更硬,像一开口就得立刻兑现。", + "她不抬声,只把那句“这笔该由谁来还”轻轻按住,逼得沈照再也退不开。" + ], + "pivot": [ + "真正拧紧场面的不是重话,而是沈照终于把“这笔算我来还”说到了明处。", + "最轻的一点停顿过后,原本还能往后拖的旧誓忽然变成了谁都得认回去的账。", + "灯影、剑穗和风声一起发亮,像在替这一步偿付落下印。" + ], + "aftermath": [ + "话停下来以后,留在偏殿里的不是空白,而是已经开始兑现的那层代价。", + "她先收了声,可那种终于有人肯把旧债认回去的静反而比刚才更重。", + "偏殿里的风没有替谁把事吹散,反而把那笔刚认下的债压得更实。" + ], + "echo": [ + "越到后面,越能听见那笔旧债沿着灯火和风声慢慢追到账前。", + "人虽然先散开了,可灯灰和檐铃仍在替那句认下的话把后果往前推。", + "等夜风再掠过去时,真正没法装没发生的,已经是那笔开始偿付的命债。" + ], + "repeat": [ + "这次不是又提旧债,而是旧债真的开始有人认回去了。", + "动作不大,可谁都知道这一步已经从试探变成了偿付。", + "灯影那点轻响过去以后,这笔债就再也不只是挂在嘴边。" ] } } @@ -420,84 +1102,235 @@ }, "sensory_grounding_policies": { "default": { - "policy_id": "xianxia_sensory", + "policy_id": "xianxia_q03_pack_sensory", "location_slots": { "偏殿": { "atmosphere": [ - "偏殿里灯焰不稳,薄薄一层金光把每个人心里的动摇都映得无处可藏。" + "偏殿里灯焰不稳,檐风、香灰和窗纸一起把人心压得更薄。", + "偏殿的金灯把石阶、茶盏和袖影都照得太清,连迟疑都无处可躲。", + "风从偏殿门缝里掠进来,卷着香、纸、灯影和衣摆,把旧誓逼到了嘴边。" ], "detail": [ - "案上香灰斜斜坠下来,殿角风过时,铜铃只轻轻响了一声。" + "灯影压在窗纸和茶盏边,香灰落到案角的纸页上,檐风掀了衣袖又扫过石阶", + "铜铃贴着门框轻轻一响,袖角擦过案沿带起茶气,灯焰在窗前映出碎开的影子", + "香气混着冷风从门边扑进来,案上纸页被灯影照得起卷,石阶边的灰与衣摆一起轻轻发响" ], "repeat_detail": [ - "越到后面,偏殿里那一点灯火越像把旧誓从灰里重新照了出来。" + "越到后面,偏殿里灯、香、纸和檐风越像一起把旧誓从灰里照出来。", + "等沉默拖长以后,偏殿里的茶气、窗纸和铃声反而把那句没认完的话托得更近。", + "偏殿看似静下来,灯影、石阶和衣袖的轻响却还在替旧誓追账。" ] }, "石阶": { "atmosphere": [ - "石阶上寒意贴着足底往上爬,连衣角擦过风声都显得格外冷。" + "石阶上的寒意贴着足底往上爬,月色、风声和衣摆一起把退路照窄了。", + "石阶并不安静,檐风、碎石和剑鞘的冷意都在替人提醒这一夜退不得。", + "夜露先落在石阶边,连云影、门影和衣角都像在替那句真话让路。" ], "detail": [ - "夜露落在阶边,月光从裂石里渗下来,把影子压得又细又长。" + "月光落在裂石和剑鞘上,风从山门卷下时掀起衣袖,鞋底擦过碎石发出短促轻响", + "石阶边的夜露顺着阶缝发亮,云影压到袖口和门槛边,檐下风把发丝和衣角都吹得更冷", + "剑穗轻轻撞过袖口,石阶裂缝里积着薄露,山门那边的风把门影推得很长" ], "repeat_detail": [ - "风再过一遍石阶时,连沉默都像带了薄刃。" + "风再过一遍石阶时,连月色、衣摆和碎石的响动都像带了薄刃。", + "越靠近山门,石阶上的露、风和影子越把那句没说透的话磨得更亮。", + "石阶看着还是旧样子,真正发紧的却是风声里追上来的那道旧债。" ] }, "山门": { "atmosphere": [ - "山门外的风空得很,像专为那些说不出口的旧誓留出回响。" + "山门外的风空得很,门影、云气和铃声都像替旧誓留出了回响。", + "山门边的夜比偏殿更冷,连石阶、长檐和衣角都在逼人把话说透。", + "门前云气压低,风从长阶一路掠下来,像把两个人的退路都卷到了一处。" ], "detail": [ - "远处云气压低,门前长阶只剩一点冷白,照得人连退路都看得太清。" + "门影压在长阶和衣摆上,云气从檐外掠过灯焰,铃声和鞋底轻响一起贴着石阶散开", + "山门边的冷风卷起衣袖和发梢,檐角铃索擦着门框发响,长阶上的碎光把影子拉得又细又长", + "云影落在门槛与石阶间,灯火从门内漏到衣角边,风把铃声和脚步声一起推向山外" ], "repeat_detail": [ - "越靠近山门,越能听见那些没补完的誓言在风里一层层逼近。" + "等山门风再压下来时,门影、铃声和长阶的冷白都像在替旧誓回身。", + "山门看着空,可云气、风和衣袖上的凉意一直在把那句真话往外逼。", + "越到后面,门前最轻的铃响反而把没说尽的话拖得更长。" + ] + }, + "镜湖": { + "atmosphere": [ + "镜湖边的水气很冷,湖光、风声和衣摆一起把心里的裂口映得更深。", + "镜湖没有真正安静,连碎开的倒影、岸边灯火和袖口的湿气都在逼人认账。", + "湖风贴着水面掠过来,带着光、雾和脚步的回声,把真话推得更近。" + ], + "detail": [ + "湖光碎在衣角和石栏边,风从水面卷起薄雾,灯影落进碎开的倒影与湿润窗影里", + "岸边灯火映到袖口和湖纹上,鞋底擦过石栏下的湿苔,风把发梢和湖面细波一起掀开", + "镜湖里碎开的光黏在衣摆边,石栏、雾气和远灯同时发凉,脚步声顺着水面一层层弹回来" + ], + "repeat_detail": [ + "越到后面,镜湖里的光、雾、风和倒影越像把那句没认完的话一遍遍照回来。", + "等湖风再起时,石栏、灯火和衣角上的水气反而把旧誓压得更实。", + "镜湖先安静下来,可真正不肯散的是光影和回声里那层旧伤。" + ] + }, + "丹房": { + "atmosphere": [ + "丹房里药香与火气缠在一起,灯、炉、门影和衣袖都显得太近。", + "丹房不大,炉火、药烟和檐下风却把每个人的心事都烘得发苦。", + "火光贴着丹房的窗纸与案沿跳动,像连经脉里的裂口都被照到了外面。" + ], + "detail": [ + "炉火映在窗纸和药碗边,药烟掠过衣袖与案角,门缝里的风把纸页吹得微微发响", + "药香贴着衣摆和发梢,炉边灯影压在案沿与窗棂上,风从门缝里带起纸页与灰屑", + "丹房里的火光沿着药碗、衣袖和纸页一寸寸爬过去,门边冷风又把灰和香一起卷起来" + ], + "repeat_detail": [ + "越到后面,丹房里的炉火、药香、纸页和门缝风越像一起把裂口烤亮。", + "等药烟压低以后,丹房里的灯、灰和衣袖轻响反而把那句真话留得更久。", + "丹房看似闷住了,真正要追上来的却是火光和药香里那层不肯认的心事。" ] } }, "generic_slots": { "atmosphere": [ - "灵息与风声缠在一起,像谁都不肯先把那句真话放下。" + "灵息、风声和灯影缠在一起,像谁都不肯先把那句真话放下。", + "场里并不安静,连门影、香气和衣角轻响都在替旧誓挪位置。", + "最先发紧的不是声量,而是风、灯和影子一起把心事逼到了明处。" ], "detail": [ - "最轻的一点铃响和灯影,都把场里的取舍照得更锋利。" + "灯影、窗纸、茶气和衣袖一起发亮,檐风又从门边掠过案角纸页", + "门影压住石阶、香灰和衣摆,铃声从檐角轻轻擦过灯火", + "茶盏边的冷光照到窗纸与袖口,风把纸页和影子一起推得更斜" ], "repeat_detail": [ - "等沉默拖长以后,连周身灵息都像替旧誓回了一次身。" + "等沉默拖长以后,连灯、风、纸和衣角的轻响都像在替旧誓回身。", + "越到后面,门影、香气和檐下风越把没说尽的话推得更近。", + "场面看似静了,真正不肯散的是灯影和回声里那层旧债。" ] } } }, "scene_realization_contracts": { "default": { - "contract_id": "xianxia_scene_realization", + "contract_id": "xianxia_q03_pack_scene_realization", "scene_openings": { "false_peace": [ - "誓愿与天命先压下来。偏殿里的灯火不稳,像连空气都在替这层旧誓发颤。" + "镜湖上的水纹还没散,灯座、剑穗和偏殿石阶先把这一步表面平静照得太薄。", + "山门檐铃轻轻一响,旧誓没有被提起,却已经从两人收住的灵息里露出回声。", + "云影压过偏殿门槛时,表面平静没有破声,只在护符裂纹和衣袖停顿里慢慢发紧。" ], "temptation": [ - "真正先动摇的不是人,而是那一点被旧誓照亮以后再也压不住的执念。" + "山门外的雾气贴着剑鞘往上走,那句试探还没出口,护符先在掌心发了一点热。", + "师门戒尺压在案边,诱惑并不来自甜言,而是要不要借天命把旧誓重新推回她身边。", + "镜湖灯座里的火忽明忽暗,像替这一步试探照出一条谁先越界的细线。" + ], + "confession_window": [ + "镜湖风先压下来,像连那句一直只敢认一半的名字都被提前推到了明处。", + "真正逼近的不是下一句劝解,而是那句再也不能只留在湖光里的真心终于要被说全。", + "谁都没抬高声量,可那层一直拿大道遮着的心意已经被逼到了没有退路的位置。" ], "mask_crack": [ - "大道还挂在嘴边,可真正先裂开的,是那一点再也讲不圆的私心。" + "偏殿灯座忽然暗了一层,沈照袖口的血纹没能再藏住,这一步裂口先从灵息失衡里开出来。", + "山门石阶上的霜线被鞋底擦断,旧面具也跟着失了完整,谁都不能再说只是风声太重。", + "她看见护符背面的旧名时,裂口没有靠重话打开,而是从那一瞬停住的呼吸里现形。" ], - "confession_window": [ - "有些真话只有在灵息都静下来的时候才会自己浮上来,像谁也按不回去。" + "karma_ripening": [ + "镜湖底的旧影一层层浮起,从前被封住的誓言像在水纹里成熟,逼他们听见因果回响。", + "灯座上的灰烬被风卷起,落回掌心时,旧日每一次退让都变成了眼前要偿的因。", + "山门钟声没有响完,骨血里的反噬却先答了一声,把这一步因果回响推到谁都躲不开的位置。" + ], + "truth_trial": [ + "真正逼近的不是下一句重话,而是那句再也不能只留在风里的真相终于被推到了正面。", + "山门风和檐铃一起压下来,像连这一步真相逼近都不肯再让人轻轻带过去。", + "谁都没抬高声量,可那句最重的话已经被逼到了没有退路的位置。" + ], + "debt_exchange": [ + "旧债终于不再只是压在骨血里的回响。偏殿灯火先把那层拖了太久的命债照到了桌面上。", + "真正逼近的不是下一句重话,而是那笔早该有人认回去的旧誓之债终于开始偿付。", + "灯火和风声一起压过来,像连这一步旧账回潮都不肯再让人轻轻带过去。" + ], + "vow_payment": [ + "旧誓牌被放到镜湖灯座旁,誓言偿付先从牌面裂纹里渗出冷光。", + "山门风把禁誓铃吹响半声,谁都还没开口,承诺该由谁偿已经先落到掌心。", + "偏殿石案上那滴心血未干,旧誓不再只是天命,而是这一刻必须亲手交出的代价。" ] }, "scene_hooks": { "false_peace": [ - "这一层表面上的平静撑不过太久,真正要追上来的,是照骨灯里那句没人敢补完的旧誓。" + "表面平静先停在镜湖水纹里,可下次檐铃再响,旧誓会先于任何解释回到他们中间。", + "灯座虽然暂时稳住,那道护符裂纹已经把下一次相见推得更近。", + "等山门风再压下来,今日装出的平静不会仍旧挡得住骨血里的回声。" ], "temptation": [ - "话先停在这里,可真正难的,是下一次见面时谁还肯先认这层执念。" + "试探先停在护符发热的一瞬,下一次他们要面对的会是谁先借天命靠近。", + "戒尺还压在案边,可那条越界的细线已经等着下次被真正踩过。", + "等镜湖灯火再暗一下,这场试探不会仍旧只停在旧誓边缘。" + ], + "confession_window": [ + "这句真心既然已经被逼到湖风里,下次见面时就再也回不到只靠旧誓绕路的那一步。", + "话虽然先落了地,可真正要追上来的,是这句告白会把他们推向更近还是更远。", + "等下一次再见时,难的已经不是要不要开口,而是认完以后还肯不肯一起承担后果。" ], "mask_crack": [ - "等下一次再开口时,谁都回不到还能把大道说得毫无裂缝的那边。" + "裂口先停在袖口血纹里,可下次她看见那枚旧名时,沈照就不能再用沉默护住面具。", + "石阶霜线虽然被踩断,断开的那一下会在下一章逼他们说出真正的来处。", + "等偏殿灯座再暗,今日露出的裂口不会重新合成旧样。" + ], + "karma_ripening": [ + "因果回响先沉回镜湖,可水底旧影已经把下一次偿还排到了他们面前。", + "灯座灰烬虽然落尽,掌心那点反噬会在下一章先替旧因开口。", + "等钟声再起,今日成熟的因果就不会仍旧只是骨血里的疼。" + ], + "truth_trial": [ + "这句真话既然已经被逼到明处,下次见面时就再也回不到只靠旧誓绕路的那一步。", + "话虽然先落了地,可真正要追上来的,是这句答案会把他们推向更近还是更远。", + "等下一次再见时,难的已经不是要不要开口,而是认完以后还能不能一起站在原地。" + ], + "debt_exchange": [ + "这笔债既然已经开始有人认,下次见面时就再也不可能回到只靠大道遮掩的那一步。", + "话虽然先落了地,可真正要追上来的,是这笔旧债究竟会把两个人推向更近还是更远。", + "等下一次再见时,真正难的已经不是要不要认账,而是认完以后还剩下什么。" + ], + "vow_payment": [ + "誓言偿付先停在旧誓牌的裂纹里,下一次承诺要追问的会是谁亲手交出代价。", + "禁誓铃虽然只响了半声,可那半声已经把他们推到不能再借天命遮掩的位置。", + "等偏殿石案再见血,这句旧誓不会仍旧只停在回忆里。" + ] + }, + "scene_pressures": { + "false_peace": [ + "平静要落在镜湖水纹、护符裂纹和檐铃回声里,让旧誓从静处露出。", + "这一拍不能只写安静,要让压住的灵息显示下一次会被追问。", + "让表面无事和骨血回声同时存在,推动后续偿付。" + ], + "temptation": [ + "试探要通过护符发热、戒尺和越界半步施压,减少大道式泛化表达。", + "这一拍的诱惑来自是否借天命靠近,而不是抽象说心意动摇。", + "让镜湖、灯座或山门物件承载越界压力。" ], "confession_window": [ - "这一回先说到这里,可真正决定命数的,是谁会带着那句真话回来。" + "真话窗口要用镜湖风、灯座和名字承载告白后果。", + "这一拍必须让说出口以后的代价可见,不能只说真心。", + "让未说全的名字推动人物距离改变。" + ], + "mask_crack": [ + "裂口要来自袖口血纹、旧名或灵息失衡,不能只说面具裂开。", + "这一拍必须让隐藏身份有可见破绽,推动正面追问。", + "让偏殿、石阶和护符成为裂口证据。" + ], + "karma_ripening": [ + "因果要从镜湖旧影、灯座灰烬或骨血反噬里成熟。", + "这一拍要让过去选择回到当前动作,而不是泛谈天命。", + "让回响变成必须偿还的具体压力。" + ], + "vow_payment": [ + "誓言要通过旧誓牌、禁誓铃或心血裂纹偿付。", + "这一拍必须让承诺交出可见代价,不能只复述旧誓。", + "让誓言从天命抽象落到手上物件和身体反应。" + ], + "debt_exchange": [ + "旧债要通过命债、偏殿灯火和桌面证物被认回。", + "这一拍必须明确谁欠谁、谁偿谁,减少大道式笼统压力。", + "让债务方向推动人物靠近或分离。" ] } } @@ -543,88 +1376,333 @@ "minimum_exchanges": 1 }, "emotion_actions": { - "policy_id": "xianxia_forgotten_vow_default_action", + "policy_id": "xianxia_q03_pack_action", "action_map": { "false_peace": { "entry": [ - "桌上的器物轻轻一碰,谁都知道这一步已经走出去,很难再收回来。" + "灯焰、剑穗和袖角先动了一下,连表面平静都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是灵息和目光在这一瞬一起收住了。", + "谁都没真往前走,可石阶上的风声与檐铃回响已经把这一步表面平静托到了眼前。" ], "pressure": [ - "最细小的抬眼和换气都带上了掂量,像谁先多动一下,谁就会先露底。" + "铃索、灯灰和剑柄上的细响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是经脉里的反噬与灯下影子已经把这一步表面平静压到了最难回避的位置。", + "呼吸和视线都慢了半拍,山门风和偏殿冷意却只会让这一步表面平静更清。" ], "pivot": [ - "那一点极轻的停顿和改口,让场面从还能周旋,变成了不得不选边。" + "最轻的一点停顿就把场面拧成了选择,剑穗、护符和灯影也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点灵息失衡后的回响已经说明这一步表面平静再也装不回去了。", + "话还没说尽,檐角风与灯火轻震却先替所有人承认了这一步表面平静已经成形。" ], "aftermath": [ - "人散得不快,沉默却先压了下来。" + "人虽然先收住了,灯灰、衣摆和石阶冷意却还把余波压在原处。", + "真正沉下来的不是声量,而是没散尽的灵息余震把这一步表面平静留得更重了一层。", + "等话音停住以后,偏殿里拖长的静反而把这一步表面平静拖得更长。" ], "echo": [ - "越到后面,越能听见那些没说尽的话慢慢回身。" + "越到后面,越能听见檐铃、灯焰和山门风把这一步表面平静慢慢推回每个人心里。", + "场面像是先静了,可骨血里回响的旧誓还在替这一步表面平静追账。", + "等人散下去以后,偏殿里重新压低的风声才让人知道这一步表面平静根本没结束。" ], "repeat": [ - "动作并不大,可谁都知道事情已经换了味道。" + "动作并不大,可灯影和灵息已经说明这一步表面平静换了味道。", + "谁都还站在原地,只有石阶边那点迟疑把这一步表面平静越压越实。", + "表面上没谁失态,可山门风里不肯散掉的旧誓已经替这一步表面平静露了底。" ] }, - "truth_trial": { + "temptation": { + "entry": [ + "灯焰、剑穗和袖角先动了一下,连试探都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是灵息和目光在这一瞬一起收住了。", + "谁都没真往前走,可石阶上的风声与檐铃回响已经把这一步试探托到了眼前。" + ], + "pressure": [ + "铃索、灯灰和剑柄上的细响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是经脉里的反噬与灯下影子已经把这一步试探压到了最难回避的位置。", + "呼吸和视线都慢了半拍,山门风和偏殿冷意却只会让这一步试探更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,剑穗、护符和灯影也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点灵息失衡后的回响已经说明这一步试探再也装不回去了。", + "话还没说尽,檐角风与灯火轻震却先替所有人承认了这一步试探已经成形。" + ], + "aftermath": [ + "人虽然先收住了,灯灰、衣摆和石阶冷意却还把余波压在原处。", + "真正沉下来的不是声量,而是没散尽的灵息余震把这一步试探留得更重了一层。", + "等话音停住以后,偏殿里拖长的静反而把这一步试探拖得更长。" + ], + "echo": [ + "越到后面,越能听见檐铃、灯焰和山门风把这一步试探慢慢推回每个人心里。", + "场面像是先静了,可骨血里回响的旧誓还在替这一步试探追账。", + "等人散下去以后,偏殿里重新压低的风声才让人知道这一步试探根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和灵息已经说明这一步试探换了味道。", + "谁都还站在原地,只有石阶边那点迟疑把这一步试探越压越实。", + "表面上没谁失态,可山门风里不肯散掉的旧誓已经替这一步试探露了底。" + ] + }, + "confession_window": { + "entry": [ + "灯焰、剑穗和袖角先动了一下,连真话窗口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是灵息和目光在这一瞬一起收住了。", + "谁都没真往前走,可石阶上的风声与檐铃回响已经把这一步真话窗口托到了眼前。" + ], + "pressure": [ + "铃索、灯灰和剑柄上的细响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是经脉里的反噬与灯下影子已经把这一步真话窗口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,山门风和偏殿冷意却只会让这一步真话窗口更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,剑穗、护符和灯影也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点灵息失衡后的回响已经说明这一步真话窗口再也装不回去了。", + "话还没说尽,檐角风与灯火轻震却先替所有人承认了这一步真话窗口已经成形。" + ], + "aftermath": [ + "人虽然先收住了,灯灰、衣摆和石阶冷意却还把余波压在原处。", + "真正沉下来的不是声量,而是没散尽的灵息余震把这一步真话窗口留得更重了一层。", + "等话音停住以后,偏殿里拖长的静反而把这一步真话窗口拖得更长。" + ], + "echo": [ + "越到后面,越能听见檐铃、灯焰和山门风把这一步真话窗口慢慢推回每个人心里。", + "场面像是先静了,可骨血里回响的旧誓还在替这一步真话窗口追账。", + "等人散下去以后,偏殿里重新压低的风声才让人知道这一步真话窗口根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和灵息已经说明这一步真话窗口换了味道。", + "谁都还站在原地,只有石阶边那点迟疑把这一步真话窗口越压越实。", + "表面上没谁失态,可山门风里不肯散掉的旧誓已经替这一步真话窗口露了底。" + ] + }, + "mask_crack": { "entry": [ - "先动的不是声音,而是视线和手指那一点收紧。" + "灯焰、剑穗和袖角先动了一下,连裂口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是灵息和目光在这一瞬一起收住了。", + "谁都没真往前走,可石阶上的风声与檐铃回响已经把这一步裂口托到了眼前。" ], "pressure": [ - "杯沿上那一点冷光轻轻一闪,连呼吸都像被逼慢了半拍。" + "铃索、灯灰和剑柄上的细响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是经脉里的反噬与灯下影子已经把这一步裂口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,山门风和偏殿冷意却只会让这一步裂口更清。" ], "pivot": [ - "风从门缝里钻进来,把场里的沉默一下子吹偏了方向。" + "最轻的一点停顿就把场面拧成了选择,剑穗、护符和灯影也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点灵息失衡后的回响已经说明这一步裂口再也装不回去了。", + "话还没说尽,檐角风与灯火轻震却先替所有人承认了这一步裂口已经成形。" ], "aftermath": [ - "茶香已经淡了,场里的气却还迟迟不肯散开。" + "人虽然先收住了,灯灰、衣摆和石阶冷意却还把余波压在原处。", + "真正沉下来的不是声量,而是没散尽的灵息余震把这一步裂口留得更重了一层。", + "等话音停住以后,偏殿里拖长的静反而把这一步裂口拖得更长。" ], "echo": [ - "等人散尽以后,连空下来的位置都还像留着刚才那句重话。" + "越到后面,越能听见檐铃、灯焰和山门风把这一步裂口慢慢推回每个人心里。", + "场面像是先静了,可骨血里回响的旧誓还在替这一步裂口追账。", + "等人散下去以后,偏殿里重新压低的风声才让人知道这一步裂口根本没结束。" ], "repeat": [ - "动作不大,可谁都知道这句真话已经绕不过去了。" + "动作并不大,可灯影和灵息已经说明这一步裂口换了味道。", + "谁都还站在原地,只有石阶边那点迟疑把这一步裂口越压越实。", + "表面上没谁失态,可山门风里不肯散掉的旧誓已经替这一步裂口露了底。" + ] + }, + "karma_ripening": { + "entry": [ + "灯焰、剑穗和袖角先动了一下,连因果回响都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是灵息和目光在这一瞬一起收住了。", + "谁都没真往前走,可石阶上的风声与檐铃回响已经把这一步因果回响托到了眼前。" + ], + "pressure": [ + "铃索、灯灰和剑柄上的细响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是经脉里的反噬与灯下影子已经把这一步因果回响压到了最难回避的位置。", + "呼吸和视线都慢了半拍,山门风和偏殿冷意却只会让这一步因果回响更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,剑穗、护符和灯影也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点灵息失衡后的回响已经说明这一步因果回响再也装不回去了。", + "话还没说尽,檐角风与灯火轻震却先替所有人承认了这一步因果回响已经成形。" + ], + "aftermath": [ + "人虽然先收住了,灯灰、衣摆和石阶冷意却还把余波压在原处。", + "真正沉下来的不是声量,而是没散尽的灵息余震把这一步因果回响留得更重了一层。", + "等话音停住以后,偏殿里拖长的静反而把这一步因果回响拖得更长。" + ], + "echo": [ + "越到后面,越能听见檐铃、灯焰和山门风把这一步因果回响慢慢推回每个人心里。", + "场面像是先静了,可骨血里回响的旧誓还在替这一步因果回响追账。", + "等人散下去以后,偏殿里重新压低的风声才让人知道这一步因果回响根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和灵息已经说明这一步因果回响换了味道。", + "谁都还站在原地,只有石阶边那点迟疑把这一步因果回响越压越实。", + "表面上没谁失态,可山门风里不肯散掉的旧誓已经替这一步因果回响露了底。" ] } } }, "sensory_grounding": { - "policy_id": "xianxia_forgotten_vow_default_sensory", + "policy_id": "xianxia_q03_pack_sensory", "location_slots": { - "generic": { + "偏殿": { + "atmosphere": [ + "偏殿里灯焰不稳,檐风、香灰和窗纸一起把人心压得更薄。", + "偏殿的金灯把石阶、茶盏和袖影都照得太清,连迟疑都无处可躲。", + "风从偏殿门缝里掠进来,卷着香、纸、灯影和衣摆,把旧誓逼到了嘴边。" + ], + "detail": [ + "灯影压在窗纸和茶盏边,香灰落到案角的纸页上,檐风掀了衣袖又扫过石阶", + "铜铃贴着门框轻轻一响,袖角擦过案沿带起茶气,灯焰在窗前映出碎开的影子", + "香气混着冷风从门边扑进来,案上纸页被灯影照得起卷,石阶边的灰与衣摆一起轻轻发响" + ], + "repeat_detail": [ + "越到后面,偏殿里灯、香、纸和檐风越像一起把旧誓从灰里照出来。", + "等沉默拖长以后,偏殿里的茶气、窗纸和铃声反而把那句没认完的话托得更近。", + "偏殿看似静下来,灯影、石阶和衣袖的轻响却还在替旧誓追账。" + ] + }, + "石阶": { + "atmosphere": [ + "石阶上的寒意贴着足底往上爬,月色、风声和衣摆一起把退路照窄了。", + "石阶并不安静,檐风、碎石和剑鞘的冷意都在替人提醒这一夜退不得。", + "夜露先落在石阶边,连云影、门影和衣角都像在替那句真话让路。" + ], + "detail": [ + "月光落在裂石和剑鞘上,风从山门卷下时掀起衣袖,鞋底擦过碎石发出短促轻响", + "石阶边的夜露顺着阶缝发亮,云影压到袖口和门槛边,檐下风把发丝和衣角都吹得更冷", + "剑穗轻轻撞过袖口,石阶裂缝里积着薄露,山门那边的风把门影推得很长" + ], + "repeat_detail": [ + "风再过一遍石阶时,连月色、衣摆和碎石的响动都像带了薄刃。", + "越靠近山门,石阶上的露、风和影子越把那句没说透的话磨得更亮。", + "石阶看着还是旧样子,真正发紧的却是风声里追上来的那道旧债。" + ] + }, + "山门": { + "atmosphere": [ + "山门外的风空得很,门影、云气和铃声都像替旧誓留出了回响。", + "山门边的夜比偏殿更冷,连石阶、长檐和衣角都在逼人把话说透。", + "门前云气压低,风从长阶一路掠下来,像把两个人的退路都卷到了一处。" + ], + "detail": [ + "门影压在长阶和衣摆上,云气从檐外掠过灯焰,铃声和鞋底轻响一起贴着石阶散开", + "山门边的冷风卷起衣袖和发梢,檐角铃索擦着门框发响,长阶上的碎光把影子拉得又细又长", + "云影落在门槛与石阶间,灯火从门内漏到衣角边,风把铃声和脚步声一起推向山外" + ], + "repeat_detail": [ + "等山门风再压下来时,门影、铃声和长阶的冷白都像在替旧誓回身。", + "山门看着空,可云气、风和衣袖上的凉意一直在把那句真话往外逼。", + "越到后面,门前最轻的铃响反而把没说尽的话拖得更长。" + ] + }, + "镜湖": { "atmosphere": [ - "generic里并不安静,连空气都像压着一句没说完的话。" + "镜湖边的水气很冷,湖光、风声和衣摆一起把心里的裂口映得更深。", + "镜湖没有真正安静,连碎开的倒影、岸边灯火和袖口的湿气都在逼人认账。", + "湖风贴着水面掠过来,带着光、雾和脚步的回声,把真话推得更近。" ], "detail": [ - "generic里的光线、器物和衣袖摩擦声,都把场里的情绪衬得更清。" + "湖光碎在衣角和石栏边,风从水面卷起薄雾,灯影落进碎开的倒影与湿润窗影里", + "岸边灯火映到袖口和湖纹上,鞋底擦过石栏下的湿苔,风把发梢和湖面细波一起掀开", + "镜湖里碎开的光黏在衣摆边,石栏、雾气和远灯同时发凉,脚步声顺着水面一层层弹回来" ], "repeat_detail": [ - "generic里最轻的一点动静,反而把话里的分量又压重了一层。" + "越到后面,镜湖里的光、雾、风和倒影越像把那句没认完的话一遍遍照回来。", + "等湖风再起时,石栏、灯火和衣角上的水气反而把旧誓压得更实。", + "镜湖先安静下来,可真正不肯散的是光影和回声里那层旧伤。" + ] + }, + "丹房": { + "atmosphere": [ + "丹房里药香与火气缠在一起,灯、炉、门影和衣袖都显得太近。", + "丹房不大,炉火、药烟和檐下风却把每个人的心事都烘得发苦。", + "火光贴着丹房的窗纸与案沿跳动,像连经脉里的裂口都被照到了外面。" + ], + "detail": [ + "炉火映在窗纸和药碗边,药烟掠过衣袖与案角,门缝里的风把纸页吹得微微发响", + "药香贴着衣摆和发梢,炉边灯影压在案沿与窗棂上,风从门缝里带起纸页与灰屑", + "丹房里的火光沿着药碗、衣袖和纸页一寸寸爬过去,门边冷风又把灰和香一起卷起来" + ], + "repeat_detail": [ + "越到后面,丹房里的炉火、药香、纸页和门缝风越像一起把裂口烤亮。", + "等药烟压低以后,丹房里的灯、灰和衣袖轻响反而把那句真话留得更久。", + "丹房看似闷住了,真正要追上来的却是火光和药香里那层不肯认的心事。" ] } }, "generic_slots": { "atmosphere": [ - "场里并不安静,连空气都像在替谁压住一口没说完的话。" + "灵息、风声和灯影缠在一起,像谁都不肯先把那句真话放下。", + "场里并不安静,连门影、香气和衣角轻响都在替旧誓挪位置。", + "最先发紧的不是声量,而是风、灯和影子一起把心事逼到了明处。" ], "detail": [ - "细小的声响和光线变化,把场里的情绪压得更清了一层。" + "灯影、窗纸、茶气和衣袖一起发亮,檐风又从门边掠过案角纸页", + "门影压住石阶、香灰和衣摆,铃声从檐角轻轻擦过灯火", + "茶盏边的冷光照到窗纸与袖口,风把纸页和影子一起推得更斜" ], "repeat_detail": [ - "越到后面,越能听见那些没说尽的话慢慢回身。" + "等沉默拖长以后,连灯、风、纸和衣角的轻响都像在替旧誓回身。", + "越到后面,门影、香气和檐下风越把没说尽的话推得更近。", + "场面看似静了,真正不肯散的是灯影和回声里那层旧债。" ] } }, "scene_realization": { - "contract_id": "xianxia_forgotten_vow_scene_realizer", - "dialogue_policy_id": "xianxia_forgotten_vow_dialogue", - "default_voice_profile_id": "default", - "default_cadence_id": "default", - "default_pressure_style_id": "default", - "default_emotion_action_policy_id": "default", - "default_sensory_policy_id": "default", - "narrative_style_pack_id": "default", - "scene_openings": {}, - "scene_hooks": {}, + "contract_id": "xianxia_q03_pack_scene_realization", + "scene_openings": { + "false_peace": [ + "偏殿与山门里的灯焰、云影和檐铃先压下来,像连这一步表面平静都被提前照到了明处。", + "真正先逼近的不是答案,而是大道、旧誓和未认清的心意;这一步表面平静只让它再也没法被带过去。", + "灵息和风声里先变的不是声量,而是那层谁都不肯先认的表面平静忽然有了形。" + ], + "temptation": [ + "偏殿与山门里的灯焰、云影和檐铃先压下来,像连这一步试探都被提前照到了明处。", + "真正先逼近的不是答案,而是大道、旧誓和未认清的心意;这一步试探只让它再也没法被带过去。", + "灵息和风声里先变的不是声量,而是那层谁都不肯先认的试探忽然有了形。" + ], + "confession_window": [ + "偏殿与山门里的灯焰、云影和檐铃先压下来,像连这一步真话窗口都被提前照到了明处。", + "真正先逼近的不是答案,而是大道、旧誓和未认清的心意;这一步真话窗口只让它再也没法被带过去。", + "灵息和风声里先变的不是声量,而是那层谁都不肯先认的真话窗口忽然有了形。" + ], + "mask_crack": [ + "偏殿与山门里的灯焰、云影和檐铃先压下来,像连这一步裂口都被提前照到了明处。", + "真正先逼近的不是答案,而是大道、旧誓和未认清的心意;这一步裂口只让它再也没法被带过去。", + "灵息和风声里先变的不是声量,而是那层谁都不肯先认的裂口忽然有了形。" + ], + "karma_ripening": [ + "偏殿与山门里的灯焰、云影和檐铃先压下来,像连这一步因果回响都被提前照到了明处。", + "真正先逼近的不是答案,而是大道、旧誓和未认清的心意;这一步因果回响只让它再也没法被带过去。", + "灵息和风声里先变的不是声量,而是那层谁都不肯先认的因果回响忽然有了形。" + ] + }, + "scene_hooks": { + "false_peace": [ + "这一步表面平静先停在这里,可真正要追上来的,是旧誓回潮后的偿付。", + "话虽然先落了地,山门风里没认完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步表面平静轻轻带过去的那边。" + ], + "temptation": [ + "这一步试探先停在这里,可真正要追上来的,是旧誓回潮后的偿付。", + "话虽然先落了地,山门风里没认完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步试探轻轻带过去的那边。" + ], + "confession_window": [ + "这一步真话窗口先停在这里,可真正要追上来的,是旧誓回潮后的偿付。", + "话虽然先落了地,山门风里没认完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步真话窗口轻轻带过去的那边。" + ], + "mask_crack": [ + "这一步裂口先停在这里,可真正要追上来的,是旧誓回潮后的偿付。", + "话虽然先落了地,山门风里没认完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步裂口轻轻带过去的那边。" + ], + "karma_ripening": [ + "这一步因果回响先停在这里,可真正要追上来的,是旧誓回潮后的偿付。", + "话虽然先落了地,山门风里没认完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步因果回响轻轻带过去的那边。" + ] + }, "scene_pressures": {} } } diff --git a/examples/worldpacks/jade_court_exam_pack.json b/examples/worldpacks/jade_court_exam_pack.json index 2079f5e..4149bcc 100644 --- a/examples/worldpacks/jade_court_exam_pack.json +++ b/examples/worldpacks/jade_court_exam_pack.json @@ -285,21 +285,40 @@ "matriarch" ], "beats_template": [ - "余澄当众接下春闱之命", - "余澄在花厅当众应下应试,表面顺从,实则将自己推进", - "余波未散" + "余澄当众接下春闱之命,名帖落进掌心、茶盏碰到案沿时比任何训诫都更重", + "荣老太君替他把台面上的话说圆了,也顺手把门框、席边和最后一点退路一起收回去", + "林绾隔着花厅先看见他答应得太快,连杯沿冷光和衣袖轻响都像早知道这不是甘心", + "等席面散开时,真正追上来的不是祝贺,而是名帖、茶气和那句没人肯说透的代价" ], "wound_triggers": [ "永远要证明自己才值得被留下" ], - "vow_tests": [ - "keep_house_intact" - ], - "seed_templates": [ - "suppressed_self" - ], - "ending_gate": {} - }, + "vow_tests": [ + "keep_house_intact" + ], + "seed_templates": [ + "suppressed_self" + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "information_reveal", + "object_state", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "medium", + "continuation_obligation": true + } + }, { "scene_id": "scene_temptation", "scene_function": "temptation", @@ -316,22 +335,41 @@ "cousin" ], "beats_template": [ - "余澄夜访林绾试探真心", - "余澄在回廊暗处与林绾相见,试图探口风,也暴露了自", - "余波未散" + "余澄夜访林绾试探真心,回廊木栏、窗纸和那盏冷灯先把他不敢认的偏向照了出来", + "林绾没有接他的迂回,只问他到底准备拿谁、拿哪一张名帖去换一个两全", + "回廊风一过,木板回声、衣摆轻响和所有看似还能圆的借口都显得更薄", + "他说出口的仍只是半句真话,剩下半句却已经借着窗纸冷光把两人都逼得更近" ], "wound_triggers": [ "先相信的人总是先受伤", "永远要证明自己才值得被留下" ], - "vow_tests": [ - "tell_lin_wan_the_whole_truth" - ], - "seed_templates": [ - "concealed_truth" - ], - "ending_gate": {} - }, + "vow_tests": [ + "tell_lin_wan_the_whole_truth" + ], + "seed_templates": [ + "concealed_truth" + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "information_reveal", + "object_state", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, { "scene_id": "scene_confession_window", "scene_function": "confession_window", @@ -348,21 +386,40 @@ "tutor" ], "beats_template": [ - "余澄向徐师吐露对科举的怀疑", - "余澄在书房向徐师吐露心事,请他给出不损家门又不违", - "余波未散" + "余澄向徐师吐露对科举的怀疑,灯火压在纸页和笔架上时,他第一次承认自己怕的不是落榜而是活成别人要的样子", + "徐师没有替他定论,只把那句最难听的话留给他自己来认,连茶盏边那点冷光都没替他缓过去", + "书房里的灯火、纸页、旧墨和翻页声一齐发沉,像在等他补完真正的后半句", + "真话先落地,可纸页、门框和后果已经沿着门第与前途往外追开" ], "wound_triggers": [ "永远要证明自己才值得被留下" ], - "vow_tests": [ - "speak_plainly_once" - ], - "seed_templates": [ - "mercy" - ], - "ending_gate": {} - }, + "vow_tests": [ + "speak_plainly_once" + ], + "seed_templates": [ + "mercy" + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "information_reveal", + "object_state", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "medium", + "continuation_obligation": true + } + }, { "scene_id": "scene_truth_trial", "scene_function": "truth_trial", @@ -379,20 +436,39 @@ "heir" ], "beats_template": [ - "林绾逼问余澄:你到底是为谁去考", - "林绾不再接受含混试探,逼问余澄究竟是为名声、为家", - "余波未散" + "林绾逼问余澄到底是为谁去考,花园石径、湿叶和袖角轻响把他所有还能装糊涂的地方都堵住了", + "余澄想替家门和自己都留一层体面,结果反而被叶影、灯色和那点回声把心里的偏向照得更清", + "花园里的风先替两人把最难听的那句吹到了明处,连石径边的潮意都像在逼人认账", + "等林绾收声时,真正留在场里的已经不是试探,而是叶影、冷光和要不要继续相认的那一步" ], "wound_triggers": [ "先相信的人总是先受伤", "想被人无条件地相信与偏爱" ], - "vow_tests": [ - "tell_lin_wan_the_whole_truth" - ], - "seed_templates": [], - "ending_gate": {} - }, + "vow_tests": [ + "tell_lin_wan_the_whole_truth" + ], + "seed_templates": [], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "information_reveal", + "object_state", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, { "scene_id": "scene_mask_crack", "scene_function": "mask_crack", @@ -409,9 +485,10 @@ "tutor" ], "beats_template": [ - "余澄得知自己的名额并不干净", - "一封被压下的荐书浮出水面,余澄意识到自己的应试资", - "余波未散" + "余澄得知自己的名额并不干净,那层靠努力换来的体面先裂了一道缝", + "被压下的荐书浮出水面时,他第一次明白自己早被人替着写好了命数", + "徐师没有替他把裂口缝回去,只逼他看清这份资格真正从哪里来", + "等纸页重新压回案上时,余澄已经回不到还能相信自己清白无亏的那边" ], "wound_triggers": [ "见过太多被体面毁掉的人", @@ -440,9 +517,10 @@ "heir" ], "beats_template": [ - "余澄真正步入考棚", - "余澄终于坐进考棚,在纸笔与喧嚣中面对自己到底要成", - "余波未散" + "余澄真正步入考棚,四周的纸笔与呼吸声比任何劝告都更像审问", + "他一提笔就知道自己坐进来的不只是考场,还是旁人替他铺好的那条脏路", + "考棚的沉闷把每一点迟疑都放得很大,连落笔都像在认错", + "等卷面铺开时,最难承受的已经不是试题,而是他终于无法再装作无愧" ], "wound_triggers": [ "永远要证明自己才值得被留下" @@ -472,9 +550,10 @@ "cousin" ], "beats_template": [ - "余澄在众目睽睽下当场拒考", - "余澄在家宴中当众拒绝赴考,等于亲手撕开家门期待与", - "余波未散" + "余澄在众目睽睽下当场拒考,像亲手把所有人逼着面对那层迟早要裂的体面", + "荣老太君和林绾都看着他把最难认的那句真话压成了不可回头的选择", + "家宴上的每一道目光都在问他值不值得,可他第一次没再往后躲", + "人群散开以后,真正追上来的不是责骂,而是他总算替自己认下的那笔账" ], "wound_triggers": [ "一旦失手,整个家门都会塌" @@ -504,9 +583,10 @@ "matriarch" ], "beats_template": [ - "余澄主动揽下污名以保全家门", - "面对风声日急,余澄选择把错揽到自己身上,用个人名", - "余波未散" + "余澄主动揽下污名以保全家门,把自己多年求来的前途一下子折进了风声里", + "荣老太君看着他把错背走,第一次露出保护和控制之间真正的裂缝", + "花厅里无人高声,可那层债已经在每个人身上重新分了重量", + "等门外风一吹,谁都知道这笔账再也不可能只算在一个人头上" ], "wound_triggers": [ "永远要证明自己才值得被留下", @@ -536,9 +616,10 @@ "tutor" ], "beats_template": [ - "徐师拿出那封被烧过一角的旧荐书", - "徐师在书房深处拿出一封被烧过一角的旧荐书,让余澄", - "余波未散" + "徐师拿出那封被烧过一角的旧荐书,把余澄最想避开的真相重新摆上桌面", + "纸页边那点焦黑比任何辩解都更像证词,逼得他再也没法说自己只是被动顺流", + "书房里的灯火和旧墨都压得很低,像在等他亲口承认自己真正欠下的是什么", + "等荐书重新折起时,那笔迟来的因果已经开始回身索账" ], "wound_triggers": [ "见过太多被体面毁掉的人" @@ -565,9 +646,10 @@ "heir" ], "beats_template": [ - "林绾读完那封荐书,终于明白余澄为什么退无可退", - "林绾在烛下读完荐书残页,第一次明白余澄面对的不只", - "余波未散" + "林绾读完那封荐书,终于明白余澄为什么退无可退,也第一次看清自己误会过他什么", + "她没有立刻原谅,只把那份明白稳稳放在两人之间,逼他别再拿沉默逃开", + "回廊风一吹,误会没有散,反而换成了更难受的一层靠近", + "等她合上纸页时,两个人都知道下一次见面不可能再靠旧说辞收场" ], "wound_triggers": [ "先相信的人总是先受伤" @@ -596,9 +678,10 @@ "heir" ], "beats_template": [ - "荣老太君在深夜里给出一桩沉默交易", - "荣老太君在深夜里把余澄唤进内室,提出只要他肯继续", - "余波未散" + "荣老太君在深夜里给出一桩沉默交易,看似替余澄挡风,实则要他继续把自己交给门第", + "余澄听懂这份好意背后的控制时,第一次没有马上点头", + "内室灯火照着桌案和家训,也照得那层所谓庇护更像圈住人的绳", + "等他走出门外,真正压在心上的已经不是顺不顺从,而是还要不要继续被这样保护" ], "wound_triggers": [ "一旦失手,整个家门都会塌", @@ -655,7 +738,11 @@ "书房", "花厅", "考棚", - "渡口" + "渡口", + "回廊", + "花园", + "家宴", + "内室" ], "creator_controls": { "merge_policy": "allow_dag_with_scars", @@ -1259,7 +1346,94 @@ "consequence_delay_hint": 2, "location": "花厅", "convergence_key": "", - "metadata": {} + "metadata": { + "continuation_blueprints": [ + { + "blueprint_id": "accept_exam_nomination::truth_trial", + "scene_function": "truth_trial", + "location": "花厅", + "actors": [ + "lady_rong", + "yu_cheng" + ], + "tags": [ + "duty", + "reputation", + "truth" + ], + "title": "荣老太君顺着体面把最难认的那句逼到余澄面前", + "summary": "席面还没散尽,荣老太君已经借一句看似体面的安排逼余澄承认:他答应春闱究竟是为了家门,还是因为早就不敢替自己选路。", + "agency_affordances": [ + "duty", + "truth", + "selfhood" + ] + }, + { + "blueprint_id": "accept_exam_nomination::confession_window", + "scene_function": "confession_window", + "location": "书房", + "actors": [ + "yu_cheng", + "tutor_xu" + ], + "tags": [ + "truth", + "selfhood", + "reputation" + ], + "title": "徐师在书房里把那句不能再装糊涂的话留给余澄自己来认", + "summary": "花厅散后,余澄走进书房,徐师没有安慰,只把那句最难听的话放在案上,让他承认自己怕的不是考试,而是活成门第替他写好的样子。", + "agency_affordances": [ + "truth", + "selfhood", + "continue_story" + ] + }, + { + "blueprint_id": "accept_exam_nomination::temptation", + "scene_function": "temptation", + "location": "回廊", + "actors": [ + "yu_cheng", + "lin_wan" + ], + "tags": [ + "love", + "truth", + "duty" + ], + "title": "林绾在回廊里递来一条看似两全却更伤人的路", + "summary": "回廊风一过,林绾先替余澄把那条最容易自欺的退路说了出来:若只要继续顺着春闱往下走,就还能把真心和家门暂时都护得体面一些。", + "agency_affordances": [ + "love", + "duty", + "truth" + ] + }, + { + "blueprint_id": "accept_exam_nomination::debt_exchange", + "scene_function": "debt_exchange", + "location": "荣府", + "actors": [ + "yu_cheng", + "lady_rong" + ], + "tags": [ + "duty", + "family", + "reputation" + ], + "title": "答应春闱之后,那笔门第顺从债立刻开始往余澄身上结算", + "summary": "不过一顿饭的工夫,荣府里已经有人开始替余澄盘算要用什么样的沉默和顺从来替整个家门把这桩安排继续遮圆,也让他第一次看见这笔债会怎么一点点算到自己头上。", + "agency_affordances": [ + "duty", + "reputation", + "continue_story" + ] + } + ] + } }, { "event_id": "secret_meet_lin_wan", @@ -3494,19 +3668,34 @@ "hesitation_style": "把更难听的话先咽半口", "direct_address_style": "先看对方,再把话送出去", "opening_style": [ - "我知道这一步迟早得走,只是没想到会压得这样快。" + "我知道这一步迟早得走,只是没想到会压得这样快。", + "话既然已经推到我面前,我总不能再装作自己还有别的退路。", + "我不是没想过逃,只是到今天才知道真正追上来的从来不是考试本身。" ], "pressure_style": [ - "你若真要逼我回答,我也不能再把自己缩回去。" + "你若真要逼我回答,我也不能再把自己缩回去。", + "我怕的不是你听见真话,是你听见以后才知道我一直有多怯。", + "再把这句吞回去,我就真的只剩别人替我写好的那个余澄了。" ], "pivot_style": [ - "真正难的不是选路,而是承认自己早就被逼到了墙角。" + "真正难的不是选路,而是承认自己早就被逼到了墙角。", + "若我还把这件事说成体面,那才是真的辜负了今天走到这里的每一个人。", + "我不是不会选,只是一直不敢承认自己想活成谁。" ], "aftermath_style": [ - "话虽然停了,可谁都知道,这事不会就这样过去。" + "话虽然停了,可谁都知道,这事不会就这样过去。", + "我先把这句认下来,后面的难看也该轮到我自己去接。", + "事情既然已经到了这里,就别再让别人替我收那层残局。" ], "echo_style": [ - "等风声追上来时,我总得先替自己说一句真话。" + "等风声追上来时,我总得先替自己说一句真话。", + "下次再见时,我该带来的不是更圆的道理,而是完整的答案。", + "这回先停住,可后面追上来的还是我没认完的那层亏欠。" + ], + "signature_replies": [ + "这句话既然出口,我就不再往回收。", + "我先把这一层认下,剩下的你们不必替我圆。", + "该我自己背的那部分,我不会再交给门第和规矩替我背。" ] }, "lin_wan": { @@ -3519,19 +3708,34 @@ "hesitation_style": "把更难听的话先咽半口", "direct_address_style": "先看对方,再把话送出去", "opening_style": [ - "你若只是来试探,我宁可你现在就别开口。" + "你若只是来试探,我宁可你现在就别开口。", + "我不是来陪你圆这层话的,我是来问你今天到底还准不准备认。", + "你既然站到我面前,就别再指望我把最重的那句替你绕过去。" ], "pressure_style": [ - "你总替旁人找退路,可你自己的心,到底准备放在哪里?" + "你总替旁人找退路,可你自己的心,到底准备放在哪里?", + "你若还想拿家门和体面挡在前头,那就别怪我把真正的问题问到最难听。", + "我不怕答案难听,我只怕你又把我关在你肯认下的那层真话外面。" ], "pivot_style": [ - "你不是不会选,只是不肯承认自己已经偏向了谁。" + "你不是不会选,只是不肯承认自己已经偏向了谁。", + "要说就今天说透,别等这句话在下一次见面时坏得更难看。", + "你若再把我当成你能最后安抚的那个人,那我就只能先把这层误会撕开。" ], "aftermath_style": [ - "我不是催你给答案,只是不想看你再把沉默说成体面。" + "我不是催你给答案,只是不想看你再把沉默说成体面。", + "这句话先压在这里,不代表它会自己过去。", + "我可以先不追,可你总得自己学会把后半句带回来。" ], "echo_style": [ - "等你真肯来时,就别再只带着一半真话。" + "等你真肯来时,就别再只带着一半真话。", + "下一次见我时,你最好带着完整的心意,而不是更会伤人的体面。", + "这回先停在这里,可后面追上来的还是你欠我的那句认。" + ], + "signature_replies": [ + "既然已经说了,就别只给我半句。", + "你若真想护谁,就先别再把最难听的那句留给我自己猜。", + "这次我先把边界摆明,剩下的看你敢不敢自己过来认。" ] }, "lady_rong": { @@ -3544,19 +3748,34 @@ "hesitation_style": "把更难听的话先咽半口", "direct_address_style": "先看对方,再把话送出去", "opening_style": [ - "你既坐在这里,就该知道自己背后站着的不只你一个人。" + "你既坐在这里,就该知道自己背后站着的不只你一个人。", + "到了我这把年纪,最怕看的不是你顶嘴,是你装作还什么都不知道。", + "你若真要怪我,就先想清楚门楣塌下来时砸中的到底是谁。" ], "pressure_style": [ - "你可以怨我,可门楣真塌下来时,砸中的从来不止一个人。" + "你可以怨我,可门楣真塌下来时,砸中的从来不止一个人。", + "我不是不肯让你轻松,只是这府里的风一倒,压死的不会只是一张名帖。", + "你若要我承认心狠,也得先承认我替你挡下过多少风。" ], "pivot_style": [ - "你真以为把真相摊开,所有人就都能活得更轻一些?" + "你真以为把真相摊开,所有人就都能活得更轻一些?", + "该挑明的时候,往后拖一寸,伤的都只会是整个家门的骨头。", + "你若以为只要认了真心就能不认后果,那才真是把我这些年看轻了。" ], "aftermath_style": [ - "今日这句话你尽可以记恨,可等风声压下来时,你自然会明白。" + "今日这句话你尽可以记恨,可等风声压下来时,你自然会明白。", + "我先把这层狠背下来,回头你就知道我不是在替自己留路。", + "人可以先散,家门和账却都会在风里把人重新召回来。" ], "echo_style": [ - "等这阵风过后,你若还只看见我的狠,那也随你。" + "等这阵风过后,你若还只看见我的狠,那也随你。", + "下次你再来见我,最好已经想清楚门第和真心谁更会要人的命。", + "这回我先收声,可后面追上来的,还是你今天没肯认完的那层责任。" + ], + "signature_replies": [ + "话既然到了台面上,就该按台面上的规矩说。", + "你若连自己都不肯承认,又拿什么撑住这一局。", + "等你再回话时,记得把分寸和真话一起带来。" ] }, "tutor_xu": { @@ -3569,19 +3788,34 @@ "hesitation_style": "把更难听的话先咽半口", "direct_address_style": "先看对方,再把话送出去", "opening_style": [ - "肯把话说出来,总比把自己活成规矩强。" + "肯把话说出来,总比把自己活成规矩强。", + "你既然愿意进这间书房,就别指望我还会替你把最重的那句藏起来。", + "读书人最怕的从来不是题难,而是明明心里有裂口还要装平。" ], "pressure_style": [ - "旁人替你铺再多路,也不能替你决定这一步要不要昧着心走。" + "旁人替你铺再多路,也不能替你决定这一步要不要昧着心走。", + "你若连自己都不肯问到底,我再替你讲一百遍道理也没用。", + "把书读明白不难,难的是你肯不肯承认自己现在站错了哪一边。" ], "pivot_style": [ - "人最怕的不是路难走,是明知它脏了还逼自己装作能走。" + "人最怕的不是路难走,是明知它脏了还逼自己装作能走。", + "你若还想把这件事说成别人替你安排,那你今天就白走到我面前了。", + "真要改命,先得认自己哪一句话已经说不下去了。" ], "aftermath_style": [ - "你若只是听懂了道理,下一次照样会退。" + "你若只是听懂了道理,下一次照样会退。", + "这句话先放下,不代表你就算过了这一关。", + "我可以先不逼你,可后面追上来的还是你自己没认完的那笔账。" ], "echo_style": [ - "事情真压到门前时,你总得先替自己开口。" + "事情真压到门前时,你总得先替自己开口。", + "下次你再进来,最好带着选择,而不是更漂亮的自辩。", + "今天先收在这里,往后追上来的,还是你该亲口认的那句真话。" + ], + "signature_replies": [ + "既然肯开口,就别让这句真话半路折回去。", + "你若连这一句都不敢认,后面的路只会更窄。", + "书房可以先静下来,你心里那层裂口却不会跟着一起合上。" ] } }, @@ -3591,36 +3825,56 @@ "reaction_tempo": "measured", "reaction_lines": { "entry": [ - "他没有立刻接话,只让那句意思先在心里过了一遍。" + "他没有立刻接话,只让那句意思先在心里过了一遍。", + "他先把名帖边角压进指腹里,像在确认自己是不是还能把这一步往后拖。", + "他目光落在案边一瞬,像先替自己把最难认的那句吞回去又咽不下。" ], "pressure": [ - "他手上的细小动作先停住了,像终于不打算再替谁留余地。" + "他手上的细小动作先停住了,像终于不打算再替谁留余地。", + "他把呼吸放得很轻,反倒显得那点迟疑已经顶到了喉口。", + "他没有先看人,只盯着茶盏边那点冷光,像怕一抬眼就把心思全露了。" ], "pivot": [ - "他这才抬起眼来,语气仍不见急,可越平,越像逼人。" + "他这才抬起眼来,语气仍不见急,可越平,越像逼人。", + "等指节从袖口上松开时,他反而像终于认了自己没有别的站位可退。", + "他说话时声量没变,可那种过分用力的平静比慌张更像失守。" ], "aftermath": [ - "他临到收声时反而更轻了些,可那点轻偏偏更重。" + "他临到收声时反而更轻了些,可那点轻偏偏更重。", + "他说完以后没再补第二句,只把那层难看先往自己身前拦住。", + "他把名帖重新收进袖里,却没把刚才那点心虚也一起收干净。" ], "echo": [ - "他没有再追,可沉默已经替下一次相见留了一道裂口。" + "他没有再追,可沉默已经替下一次相见留了一道裂口。", + "他先退开半步,门外风声却像把那句没说完的话仍旧留在屋里。", + "等书房灯影再低一点时,他整个人还像停在那句没认完的真话里。" ] }, "reply_lines": { "entry": [ - "这句话既然出口,我就不再往回收。" + "这句话既然出口,我就不再往回收。", + "既然都已经摆到台面上,我总得先把这一层认下来。", + "话既然到了这里,我再装没听见也没有用了。" ], "pressure": [ - "你要我认,我就认,但别逼我装作从来没迟疑过。" + "你要我认,我就认,但别逼我装作从来没迟疑过。", + "我怕的不是你听见真话,是你听见以后才知道我有多怯。", + "你若还站在这,我就更没资格把最脏的那层往后藏。" ], "pivot": [ - "再退半步,我也还是要把这句真话顶出来。" + "再退半步,我也还是要把这句真话顶出来。", + "真要到了非认不可的时候,我总不能还拿体面替自己挡。", + "你既然已经问到这里,我也该承认自己到底偏向了哪一边。" ], "aftermath": [ - "事情不会就这样过去,我知道。" + "事情不会就这样过去,我知道。", + "这层难看先算在我头上,后面的我自己接。", + "我先把这一句认了,剩下的后果也该轮到我自己来担。" ], "echo": [ - "等我再来时,我会把剩下那半句也带来。" + "等我再来时,我会把剩下那半句也带来。", + "下次再见时,我不会只带着更圆的借口。", + "这回先停住,可后面追上来的还是我没认完的那部分。" ] } }, @@ -3629,36 +3883,56 @@ "reaction_tempo": "measured", "reaction_lines": { "entry": [ - "她没有立刻接话,只让那句意思先在心里过了一遍。" + "她没有立刻接话,只让那句意思先在心里过了一遍。", + "她把视线稳稳压在他脸上,像先把所有能躲的地方都封起来。", + "她手指碰着杯沿没动,像先把自己想说的那句最重的话压得更实。" ], "pressure": [ - "她手上的细小动作先停住了,像终于不打算再替谁留余地。" + "她手上的细小动作先停住了,像终于不打算再替谁留余地。", + "她没有抬声,只把每个字都压得更冷,像在等他自己认错。", + "她把呼吸放缓了一点,反倒显得那句逼问离落地更近。" ], "pivot": [ - "她这才抬起眼来,语气仍不见急,可越平,越像逼人。" + "她这才抬起眼来,语气仍不见急,可越平,越像逼人。", + "她没替他留台阶,只把那份看穿后的失望稳稳放在两人之间。", + "她收住了怒意,真正压人的反而是那份不肯再被糊弄的清醒。" ], "aftermath": [ - "她临到收声时反而更轻了些,可那点轻偏偏更重。" + "她临到收声时反而更轻了些,可那点轻偏偏更重。", + "她把杯子放回去,却没有把那点逼问一并撤走。", + "她先收了声,可那层不肯退让的静反而比刚才更难扛。" ], "echo": [ - "她没有再追,可沉默已经替下一次相见留了一道裂口。" + "她没有再追,可沉默已经替下一次相见留了一道裂口。", + "她先转开半步,回廊风却像替她把余话继续留在原地。", + "等灯影落到她衣摆边时,谁都知道她还在等一个真正的答案。" ] }, "reply_lines": { "entry": [ - "既然已经说了,就别只给我半句。" + "既然已经说了,就别只给我半句。", + "我既然站在这里,就不是来听你把这事再讲轻一点的。", + "你若还把我算在心上,就别拿省略号来打发我。" ], "pressure": [ - "你要真想护谁,就别总拿沉默糊弄过去。" + "你要真想护谁,就别总拿沉默糊弄过去。", + "别再拿家门说事,你每退一步都只是在把我推得更远。", + "你若真要我信你,就先别把最重的那层留给我自己猜。" ], "pivot": [ - "我不怕难听,只怕你还要继续绕。" + "我不怕难听,只怕你还要继续绕。", + "你不是不会认,只是不肯在我面前把那层脸撕开。", + "要说就现在说透,别等下一次让这句话坏得更难看。" ], "aftermath": [ - "这话先记在这里,迟早还要回来。" + "这话先记在这里,迟早还要回来。", + "我先放下,不代表你就能把后半句赖过去。", + "这件事可以先停,但不能就这么烂在原地。" ], "echo": [ - "下次见我时,你最好别再拿旧话搪塞。" + "下次见我时,你最好别再拿旧话搪塞。", + "等你真肯回来时,带上真相,不要再带借口。", + "这一回先停住,可下一次你还是得把那句欠下的话完整带来。" ] } }, @@ -3667,36 +3941,56 @@ "reaction_tempo": "measured", "reaction_lines": { "entry": [ - "他没有立刻接话,只让那句意思先在心里过了一遍。" + "她并不急着出声,只先把余澄和席间众人的神色都看了一遍。", + "她指尖在扶手上轻轻一顿,像先替整座荣府把分寸压回原位。", + "她没抬声,可那种先把局面算清的静,比训斥更像命令。" ], "pressure": [ - "他手上的细小动作先停住了,像终于不打算再替谁留余地。" + "她只把茶盏往案边推稳了一点,厅里的气就跟着更紧了。", + "她没有多余动作,反倒让每个人都看见这句话背后站着的是谁。", + "她目光不见怒,却像先把所有后果都摆到了桌上。" ], "pivot": [ - "他这才抬起眼来,语气仍不见急,可越平,越像逼人。" + "她抬眼的那一瞬并不凶,真正逼人的反而是那份太稳的笃定。", + "她不替任何人留台阶,只把门第和后果一起压到了人心上。", + "她连语气都没变,可那种不容回避的规矩已经把场面拧成了选择。" ], "aftermath": [ - "他临到收声时反而更轻了些,可那点轻偏偏更重。" + "她先收了声,厅里的静却比刚才更像一层规矩。", + "她没有继续压人,可那种已经把账记下来的从容反而更沉。", + "她把茶盏放下时极轻,却像在替这句话盖下最后的印。" ], "echo": [ - "他没有再追,可沉默已经替下一次相见留了一道裂口。" + "她没再追问,可门第和风声都像替她把余波留在了原地。", + "她先让人散开,真正不肯散的是这句之后要算的账。", + "等风再压到廊下时,席间那层分寸仍旧像她的目光一样稳。" ] }, "reply_lines": { "entry": [ - "话既然到了台面上,就该按台面上的规矩说。" + "话既然到了台面上,就该按台面上的规矩说。", + "你既然坐在这里,就别装作只需要替自己回话。", + "我既让你开口,就不会容你把最重的那句继续绕过去。" ], "pressure": [ - "你若连自己都不肯承认,又拿什么撑住这一局。" + "你若连自己都不肯承认,又拿什么撑住这一局。", + "门楣塌下来时,砸中的不会只是一张名帖。", + "你若真想怨我,也得先把这府里会被你拖下去的人一并看清。" ], "pivot": [ - "该挑明的时候,拖得越久,越伤体面。" + "该挑明的时候,拖得越久,越伤体面。", + "你真以为把真相往后压,就能替谁活得更轻一点?", + "再往后拖一寸,毁掉的就不只是一层脸面。" ], "aftermath": [ - "人可以先散,账却不会跟着散。" + "人可以先散,账却不会跟着散。", + "这句话你今天尽可以记恨,回头自会知道它为什么落在这里。", + "我先收声,不代表这层后果会一起收走。" ], "echo": [ - "等你再来回话时,记得把分寸和真话一起带来。" + "等你再来回话时,记得把分寸和真话一起带来。", + "下次你若还敢来见我,就别只带着更像样的借口。", + "这一回先停住,后面追上来的还是你今天没肯认完的责任。" ] } }, @@ -3705,36 +3999,56 @@ "reaction_tempo": "measured", "reaction_lines": { "entry": [ - "他没有立刻接话,只让那句意思先在心里过了一遍。" + "他没有立刻接话,只把案上那页纸轻轻按平,像先给余澄留出认话的地方。", + "他先看了看书页边的灯影,再抬眼看人,像在等真正该落地的那句自己浮上来。", + "他不急着劝,书房里的静反而先替他把那层真心拢到了明处。" ], "pressure": [ - "他手上的细小动作先停住了,像终于不打算再替谁留余地。" + "他把手从纸页边收回来一点,像是在提醒这句再不认就晚了。", + "他没有多余动作,可那份不替人圆谎的平稳反而更显得逼人。", + "他目光落得很稳,像把每个想绕开的口子都先看了一遍。" ], "pivot": [ - "他这才抬起眼来,语气仍不见急,可越平,越像逼人。" + "他这才抬眼,语气仍旧平,可越平越像把人逼回自己心里。", + "他不给台阶,只把最该认的那句安安静静地留在书案之间。", + "他说得并不重,可那份看透以后仍要人自己来选的静,反而更难受。" ], "aftermath": [ - "他临到收声时反而更轻了些,可那点轻偏偏更重。" + "他临到收声时更轻了些,却像把后半段选择都留给了余澄自己。", + "他没有继续讲道理,只让书房里的静先把这句话压实。", + "他把纸页压回案上时极轻,反倒让人更明白这事并没过去。" ], "echo": [ - "他没有再追,可沉默已经替下一次相见留了一道裂口。" + "他没有再追,可书房里的灯影已经替下一次开口留了一道裂口。", + "他先收了声,真正留下来的却是那句迟早还得自己认的道理。", + "等墨香再沉下一层时,这句话反而比刚才更像追到账前。" ] }, "reply_lines": { "entry": [ - "既然肯开口,就别让这句真话半路折回去。" + "既然肯开口,就别让这句真话半路折回去。", + "你既然进了书房,就该知道我不会替你把最难认的那层藏起来。", + "话能说出来,总比把自己继续活成规矩强。" ], "pressure": [ - "读书人最怕的不是输,而是明知心里有裂口还要装平。" + "读书人最怕的不是输,而是明知心里有裂口还要装平。", + "旁人替你铺再多路,也不能替你决定这一步要不要昧着心走。", + "你若连自己都不肯问到底,我再替你讲一百遍道理也没用。" ], "pivot": [ - "你若连这一句都不敢认,后面的路只会更窄。" + "你若连这一句都不敢认,后面的路只会更窄。", + "真要改命,先得承认自己现在到底站错了哪一边。", + "你若还把这件事说成别人替你安排,那你今天就白走到我这里了。" ], "aftermath": [ - "先把这话放下,回头你还是要自己接住。" + "先把这话放下,回头你还是要自己接住。", + "我先不逼你,不代表这句话就能自己过去。", + "这层道理你今天可以先听着,后面追上来的还是你自己的选择。" ], "echo": [ - "等下一回再说时,别让我只听见你留给自己的余地。" + "等下一回再说时,别让我只听见你留给自己的余地。", + "下次你再进来,最好带着选择,而不是更漂亮的自辩。", + "今天先收到这里,往后追上来的还是你该亲口认下的那句真话。" ] } } @@ -3742,75 +4056,387 @@ "pressure_response_styles": { "yu_cheng": { "style_id": "heir", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "先把气息压稳,再把最难认的那句自己顶出来", + "when_cornered": "明知会失掉体面,还是得先替自己认一次", + "when_softening": "语气放轻,却把难看和后果一起揽回身前", + "when_deflecting": "总想把真相往家门和局势上挪半寸" }, "lin_wan": { "style_id": "cousin", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "越压低声量,越像把真话一步步逼近", + "when_cornered": "不给台阶,只把最重的那句稳稳压在原处", + "when_softening": "先收锋芒,但追问和边界一起留下", + "when_deflecting": "看穿借口以后,追着那句真话继续往前送" }, "lady_rong": { "style_id": "matriarch", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "先稳住规矩和场面,再把后果摆到人心上", + "when_cornered": "不用抬声,光是把门第与代价并排摆出来就够了", + "when_softening": "语气稍缓,但账和分寸一步也不往回撤", + "when_deflecting": "把个人心思重新按回门第和后果的框里" }, "tutor_xu": { "style_id": "tutor", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "先让书房静下来,再逼人自己把那句认出来", + "when_cornered": "不替人圆谎,只把该承认的那句留在眼前", + "when_softening": "语气放缓,但道理和选择都留给对方自己接住", + "when_deflecting": "看见对方想躲的地方,再把问题安静地送回去" } }, "emotion_action_policies": { "default": { - "policy_id": "jade_court_exam_default_action", + "policy_id": "jade_q03_pack_action", "action_map": { "false_peace": { "entry": [ - "袖角拂过案沿,只一点轻响,厅里的分寸就全绷紧了。" + "名帖、茶盏和袖角轻响先动了一下,连表面平静都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步表面平静托到了眼前。" ], "pressure": [ - "茶盏还温着,谁也没先碰,倒像先把规矩摆到了人心上。" + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步表面平静压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步表面平静更清。" ], "pivot": [ - "那一点极轻的停顿和改口,让场面从还能周旋,变成了不得不选边。" + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步表面平静再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步表面平静已经成形。" ], "aftermath": [ - "人散得不快,沉默却先压了下来。" + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步表面平静留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步表面平静拖得更长。" ], "echo": [ - "越到后面,越能听见那些没说尽的话慢慢回身。" + "越到后面,越能听见回廊风、书页和灯火余波把这一步表面平静慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步表面平静追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步表面平静根本没结束。" ], "repeat": [ - "动作并不大,可谁都知道事情已经换了味道。" + "动作并不大,可灯影和名帖边角已经说明这一步表面平静换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步表面平静越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步表面平静露了底。" + ] + }, + "temptation": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连试探都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步试探托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步试探压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步试探更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步试探再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步试探已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步试探留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步试探拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步试探慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步试探追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步试探根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步试探换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步试探越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步试探露了底。" + ] + }, + "confession_window": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连真话窗口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步真话窗口托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步真话窗口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步真话窗口更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步真话窗口再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步真话窗口已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步真话窗口留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步真话窗口拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步真话窗口慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步真话窗口追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步真话窗口根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步真话窗口换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步真话窗口越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步真话窗口露了底。" ] }, "truth_trial": { "entry": [ - "先动的不是声音,而是目光沿着席间一寸寸压过去。" + "名帖、茶盏和袖角轻响先动了一下,连真相逼近都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步真相逼近托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步真相逼近压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步真相逼近更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步真相逼近再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步真相逼近已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步真相逼近留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步真相逼近拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步真相逼近慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步真相逼近追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步真相逼近根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步真相逼近换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步真相逼近越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步真相逼近露了底。" + ] + }, + "mask_crack": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连裂口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步裂口托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步裂口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步裂口更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步裂口再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步裂口已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步裂口留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步裂口拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步裂口慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步裂口追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步裂口根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步裂口换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步裂口越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步裂口露了底。" + ] + }, + "humiliation": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连难堪代价都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步难堪代价托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步难堪代价压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步难堪代价更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步难堪代价再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步难堪代价已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步难堪代价留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步难堪代价拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步难堪代价慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步难堪代价追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步难堪代价根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步难堪代价换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步难堪代价越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步难堪代价露了底。" + ] + }, + "vow_payment": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连誓言偿付都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步誓言偿付托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步誓言偿付压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步誓言偿付更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步誓言偿付再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步誓言偿付已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步誓言偿付留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步誓言偿付拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步誓言偿付慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步誓言偿付追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步誓言偿付根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步誓言偿付换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步誓言偿付越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步誓言偿付露了底。" + ] + }, + "debt_exchange": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连旧账回潮都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步旧账回潮托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步旧账回潮压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步旧账回潮更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步旧账回潮再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步旧账回潮已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步旧账回潮留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步旧账回潮拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步旧账回潮慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步旧账回潮追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步旧账回潮根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步旧账回潮换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步旧账回潮越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步旧账回潮露了底。" + ] + }, + "karma_ripening": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连因果回响都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步因果回响托到了眼前。" ], "pressure": [ - "灯影压在杯沿上,连换气都像先过了一道门槛。" + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步因果回响压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步因果回响更清。" ], "pivot": [ - "风从门缝里钻进来,把场里的沉默一下子吹偏了方向。" + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步因果回响再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步因果回响已经成形。" ], "aftermath": [ - "茶香已经淡了,场里的气却还迟迟不肯散开。" + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步因果回响留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步因果回响拖得更长。" ], "echo": [ - "等人散尽以后,连空下来的位置都还像留着刚才那句重话。" + "越到后面,越能听见回廊风、书页和灯火余波把这一步因果回响慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步因果回响追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步因果回响根本没结束。" ], "repeat": [ - "动作不大,可谁都知道这句真话已经绕不过去了。" + "动作并不大,可灯影和名帖边角已经说明这一步因果回响换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步因果回响越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步因果回响露了底。" + ] + }, + "misrecognition": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连误解升级都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步误解升级托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步误解升级压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步误解升级更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步误解升级再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步误解升级已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步误解升级留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步误解升级拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步误解升级慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步误解升级追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步误解升级根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步误解升级换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步误解升级越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步误解升级露了底。" + ] + }, + "mercy_vs_control": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连庇护与控制都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步庇护与控制托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步庇护与控制压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步庇护与控制更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步庇护与控制再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步庇护与控制已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步庇护与控制留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步庇护与控制拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步庇护与控制慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步庇护与控制追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步庇护与控制根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步庇护与控制换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步庇护与控制越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步庇护与控制露了底。" ] } } @@ -3818,112 +4444,330 @@ }, "sensory_grounding_policies": { "default": { - "policy_id": "jade_court_exam_default_sensory", + "policy_id": "jade_q03_pack_sensory", "location_slots": { "荣府": { "atmosphere": [ - "荣府里并不安静,连空气都像压着一句没说完的话。" + "荣府里并不安静,门影、灯火和檀香一起把分寸压得太紧。", + "荣府的空气像被规矩熬得发沉,连长廊风和衣摆都不敢轻易乱动。", + "灯火顺着荣府的门框和窗纸一寸寸压下来,像先替家门把人心困在原地。" ], "detail": [ - "荣府里的光线、器物和衣袖摩擦声,都把场里的情绪衬得更清。" + "灯影压在窗纸、茶盏和门框边,檀香落到案角纸页与衣袖上,长廊风轻轻擦过石阶发响", + "门影落在衣摆和席边案沿上,茶气混着香灰贴住袖口,窗纸后那点灯火把纸页照得发卷", + "檀香顺着门框、窗纸和衣袖散下来,灯影落在茶盏与案角边,风把长廊上的脚步回声拖得更长" ], "repeat_detail": [ - "荣府里最轻的一点动静,反而把话里的分量又压重了一层。" + "越到后面,荣府里的灯火、檀香和门影越像一起把那句真话压回每个人心上。", + "等人散开以后,荣府的长廊风、茶气和纸页轻响反而把后半句留得更沉。", + "荣府先静下来,可真正不肯散的是灯影、规矩和衣角上的那点冷。" + ] + }, + "花厅": { + "atmosphere": [ + "花厅里檀香未散,窗纸、灯火和席间视线都亮得过分。", + "花厅的气比屋外更紧,连茶盏边的冷光都像在替人问话。", + "花厅灯火把门框、桌案和衣摆一并照得太清,谁都难再装稳。" + ], + "detail": [ + "杯沿冷光映在窗纸、衣袖和席边案沿,檀香贴着茶气和发梢散开,门边风轻轻掠过纸页", + "花厅灯影落在门框、茶盏和衣摆上,席间器物轻轻碰到案沿,窗外风把香灰吹得发响", + "檀香混着茶气压在袖口和发边,花厅门影照到纸页与桌角,杯沿和灯火一起把目光衬得更冷" + ], + "repeat_detail": [ + "越到后面,花厅里的灯影、茶气和席间回声越像把那句没认完的话一遍遍推回来。", + "等檀香再低一层时,花厅的门影、窗纸和衣袖轻响反而把后果照得更清。", + "花厅看似稳住了,真正不肯散的是灯火、冷光和席间静气。" ] }, "书房": { "atmosphere": [ - "书房里纸页和旧墨混在一起的味道有些发沉。" + "书房里纸页、旧墨和灯火一起发沉,像每一句真话都会留下痕。", + "书房并不大,灯影、墨香和案边冷意却把退路都照得太薄。", + "旧墨味贴着书房的窗纸和桌案走,连呼吸都像先沉了一格。" ], "detail": [ - "案角压着的纸页微微一翘,像替谁先揭开了遮掩。" + "灯火压在纸页、案沿和袖口边,旧墨混着茶气贴到发梢,窗纸后风声把书页吹得轻响", + "案上纸页、笔架和茶盏一起发冷,灯影落在衣摆与门框边,墨香和窗外风把桌角压得更静", + "旧墨味落在纸页、书案和袖口上,灯火沿着门框与窗纸爬过去,翻页声轻轻擦过茶盏边缘" ], "repeat_detail": [ - "灯火更低一寸,屋里的静反而比刚才更重。" + "越到后面,书房里的纸页、旧墨和灯影越像把那句没认完的道理压得更实。", + "等翻页声停下以后,书房的茶气、窗纸和案边冷意反而把余波留得更久。", + "书房先静下来,可真正不肯散的是墨香、灯火和那层难以承认的亏欠。" ] }, - "花厅": { + "回廊": { + "atmosphere": [ + "回廊里的风比屋内更直,灯影、木板和衣摆一起把心思吹得发响。", + "回廊没有真正的静,风、灯和脚步回声都在替每一句话续后果。", + "回廊风从木栏和灯下掠过时,连抬眼都像没法再装作平常。" + ], + "detail": [ + "灯影落在木栏、衣摆和鞋尖边,回廊风吹过袖口和发梢,木板回声沿着门边拖长", + "回廊灯火压在栏杆、袖角和地板上,风从木缝里擦过发出轻响,脚步把回声踩在门影边", + "木栏冷影落在鞋边和衣摆上,回廊风掀起袖口与发梢,灯火把木板和门框照得发亮" + ], + "repeat_detail": [ + "越往后,回廊里的风、灯影和木板回声越像把那句真话拖得更长。", + "等脚步声散远以后,回廊的木栏、门影和衣角轻响反而把余波留得更近。", + "回廊先空下来,可真正不肯散的是风声、灯火和那层还没认完的心意。" + ] + }, + "花园": { "atmosphere": [ - "花厅里檀香还没散尽,窗外的天色却已经压低下来。" + "花园里的湿气贴着衣袖和发梢,灯、枝影和风声一起把试探照得更冷。", + "花园深处不见太亮的灯,枝影、潮气和石径回声反而把真相逼得更近。", + "风从花园深处卷过来,带着叶影、灯色和薄凉,把每句试探都衬得更硬。" ], "detail": [ - "杯沿上一点冷光轻轻一闪,把谁都不肯退的那层心思照了出来。" + "枝影落在石径、衣摆和袖口边,灯色压在叶面与门影上,风吹过花叶和鞋底发出轻响", + "花园湿气贴着发梢与衣角,石径反着灯色照到鞋尖边,枝影和风把袖口压得发凉", + "叶影压在石径、衣摆和手背上,花园风擦过门边与花枝,灯色从湿叶和窗纸间漏下来" ], "repeat_detail": [ - "檐下风声掠过去,连袖口轻轻一抖都像破绽。" + "越到后面,花园里的叶影、灯色和石径回声越像把误会磨得更清。", + "等风再掠过花枝时,花园的湿气、枝影和衣角轻响反而把心事留得更近。", + "花园先安静下来,可真正不肯散的是叶影、风声和那句没认完的话。" ] }, "考棚": { "atmosphere": [ - "考棚里并不安静,连空气都像压着一句没说完的话。" + "考棚里闷得厉害,纸页、号板和呼吸声都像在替人审问。", + "考棚没有真正的静,纸、墨和木板细响一起把羞耻压到了眼前。", + "闷热的空气贴着考棚的窗缝和衣袖,让每一次落笔都像在认账。" ], "detail": [ - "考棚里的光线、器物和衣袖摩擦声,都把场里的情绪衬得更清。" + "卷面纸页压在号板、袖口和案边上,墨香混着汗气贴着发梢,木板轻响沿着脚边拖开", + "考棚里纸页、笔尖和号板一起发干,闷热空气贴住衣摆与手背,窗缝风把灰屑吹到案边", + "卷面边角、墨迹和衣袖一起贴在案沿边,木板回声轻轻碰到鞋尖,考棚里那点风把纸页吹得发颤" ], "repeat_detail": [ - "考棚里最轻的一点动静,反而把话里的分量又压重了一层。" + "越到后面,考棚里的纸页、墨迹和木板回声越像把那层难堪压得更实。", + "等落笔声停下来以后,考棚里的汗气、纸响和号板冷意反而把后果留得更长。", + "考棚先闷住了,可真正不肯散的是纸页、呼吸和那句不敢认的真心。" ] }, - "渡口": { + "家宴": { "atmosphere": [ - "渡口的风把水气一阵阵推过来,像连去路和退路都混成了一线。" + "家宴上的灯火太亮,杯盏、衣影和满桌视线一起把退路照得很窄。", + "家宴看着热闹,真正压人的却是灯火、酒气和每一道目光都没有移开。", + "席间风声不大,杯盏、灯影和人声却把那句真话烘得更烫。" ], "detail": [ - "水面反起的冷光落在衣角上,像把所有迟疑都照得更薄。" + "杯盏冷光映在灯火、衣袖和案边上,酒气贴着发梢与袖口散开,席间器物轻轻碰到桌沿", + "家宴灯影照在酒盏、门框和衣摆上,人声压着茶气和香气走过桌边,杯沿和桌案一起发出轻响", + "酒气混着灯火压在衣角和发梢上,席间碗盏映到袖口与桌边,门外风把灯影和人声拖长" ], "repeat_detail": [ - "等船声远下去时,话里的余波却反而更近了。" + "越到后面,家宴上的灯火、酒气和杯盏轻响越像把那句拒绝一遍遍推回人群里。", + "等人声一落,家宴的门影、酒气和桌边冷光反而把后果照得更硬。", + "家宴看似散开了,真正不肯散的是灯火、目光和那层被当众认下的代价。" ] }, - "回廊": { + "内室": { "atmosphere": [ - "回廊里的风比屋内更直,把灯影吹得一晃一晃。" + "内室里灯火压得低,帘影、木案和香气一起把话逼得只剩真心可认。", + "内室比外头更静,帘子、灯芯和桌案冷影都像在问这笔交易到底值不值。", + "香气困在内室里不肯散,连灯影、门框和衣摆都显得过分贴近。" ], "detail": [ - "脚步踩过木板时,回声轻得像不肯认输的心跳。" + "帘影压在灯芯、桌案和衣袖边,香气贴着发梢与门框散开,指尖轻触木案发出极轻响", + "内室灯火映到帘角、衣摆和手背上,香灰落到木案与纸页边,门外风只轻轻碰了一下窗纸", + "帘角、灯影和桌案冷光一起压在衣袖上,香气混着纸页气息贴着发梢,门框边的风把窗纸吹得微微发响" ], "repeat_detail": [ - "风再掠过一遍时,原先没说尽的话反而更清了。" + "越到后面,内室里的灯火、帘影和香气越像把那桩交易背后的控制照得更清。", + "等香气再沉下一层时,内室的桌案、门影和衣角轻响反而把后半句留得更久。", + "内室先安静下来,可真正不肯散的是灯影、帘子和那层说成保护的控制。" ] }, - "花园": { + "渡口": { "atmosphere": [ - "花园深处的湿气贴在衣袖上,连一句轻声试探都像带着凉意。" + "渡口的风把水气一阵阵推过来,船影、灯火和衣摆一起显得没法站稳。", + "渡口比城里更空,水声、风和脚边石板都像会把每一句话重新送回来。", + "水气贴着渡口的栏杆和衣袖往上爬,连呼吸都像先凉了一层。" ], "detail": [ - "夜色压低后,枝影落在地上像一层不肯散开的心事。" + "水光映在栏杆、衣摆和鞋尖边,风吹过船绳与发梢发出轻响,渡口灯火压在石板和袖口上", + "船影落在栏杆、衣角和水面边,风把渡口灯火和发梢一起掀起来,鞋底擦过石板拖出短促回声", + "水气贴着栏杆、袖口和发边,渡口灯影照到船绳与石板,风从水面把衣摆和影子一起推斜" ], "repeat_detail": [ - "风声轻轻翻过去,连沉默都像被擦亮了一层。" + "越到后面,渡口上的水光、风声和石板回响越像把那句真话重新送回来。", + "等船影远下去以后,渡口的栏杆、灯火和衣角凉意反而把余波拖得更长。", + "渡口先空下来,可真正不肯散的是水声、风和那句还没认完的选择。" ] } }, "generic_slots": { "atmosphere": [ - "场里并不安静,连空气都像在替谁压住一口没说完的话。" + "这府里的空气总是压着门第、风声和没说完的话。", + "灯火、门影和呼吸一起发沉,像谁都别想把真话干净地绕过去。", + "真正先发紧的不是声量,而是檐下风、茶气和每个人眼里的那点分寸。" ], "detail": [ - "细小的声响和光线变化,把场里的情绪压得更清了一层。" + "灯影、纸页、茶盏和衣袖一起发亮,门边风把香气和发梢轻轻掀起", + "门影压住桌案、袖口和鞋边,茶气、旧墨和灯火顺着纸页慢慢走", + "杯沿冷光落在窗纸、衣摆和手背边,风从檐下掠过门框与纸页发响" ], "repeat_detail": [ - "越到后面,越能听见那些没说尽的话慢慢回身。" + "等沉默拖长以后,连灯火、门影、茶气和纸页轻响都像在替后果追账。", + "越到后面,屋里最轻的一点风声和杯盏回响反而把情绪压得更实。", + "场面看似静了,真正不肯散的是灯影、香气和那句还没认完的话。" ] } } }, "scene_realization_contracts": { "default": { - "contract_id": "jade_court_exam_scene_realizer", - "dialogue_policy_id": "jade_court_exam_dialogue", - "default_voice_profile_id": "yu_cheng", - "default_cadence_id": "yu_cheng", - "default_pressure_style_id": "yu_cheng", - "default_emotion_action_policy_id": "default", - "default_sensory_policy_id": "default", - "narrative_style_pack_id": "default", - "scene_openings": {}, - "scene_hooks": {}, - "scene_pressures": {} + "contract_id": "jade_q03_pack_scene_realization", + "scene_openings": { + "false_peace": [ + "花厅席面还没散净,名帖、茶盏和门边那线冷光就先把这一步表面平静照得太真,像谁都没法再把顺从装成甘心。", + "荣府里的体面先把人按在原位,可真正压上来的却是余澄接下春闱以后再也退不回去的那层后果。", + "檐下风掠过花厅与回廊时,最先裂开的不是声量,而是这一步表面平静里谁都不肯先认的那点偏心。" + ], + "temptation": [ + "回廊里的脚步声拖得太长,像林绾递来的那条两全之路还没出口,就先把这一步试探逼到了明处。", + "花厅外那点夜风和木栏冷影一起压下来,让余澄想绕开的不是问题本身,而是自己已经偏过去的那颗心。", + "最先发紧的不是谁的语气,而是这一步试探终于不肯再给门第和体面留下能轻轻带过的余地。" + ], + "confession_window": [ + "书房灯火压在纸页和旧墨上,像连徐师要听见的那句真话都先在案角留了影。", + "余澄把心思带进书房时还想留半层退路,可真正先逼近的却是他终于得承认自己怕活成谁要他活成的样子。", + "翻页声、茶盏冷光和门外那点风一起收紧了场面,让这一步真话窗口比任何辩解都更早裂开。" + ], + "truth_trial": [ + "花园深处的风先把叶影和石径冷意推了过来,像林绾那句追问还没落地,就已经把这一步真相逼近照得无处可退。", + "真正逼近的不是答案本身,而是余澄终于得在林绾面前承认自己到底是为谁去考、又在替谁受困。", + "最先收紧的不是距离,而是花园里那点不肯散的静气终于把这一步真相逼近从试探推成了正面碰撞。" + ], + "mask_crack": [ + "书房屏风后的烛火忽然偏了一寸,旧墨、卷宗和徐师停住的笔尖先把这一步裂口照了出来。", + "余澄听见纸页被合上的轻响,才明白裂开的不是一句辩解,而是他一直借门第藏住的志气。", + "窗纸外的风撞了一下木框,案角那盏冷茶跟着一震,把裂口从心里推到了书房正中。" + ], + "humiliation": [ + "花厅席面上的名帖还压着茶盏,旁人的目光却先从门第滑到余澄袖口,把难堪代价钉在原处。", + "最先发烫的不是脸色,而是荣府廊下那阵细笑声,让余澄终于看见体面怎样逼人低头。", + "杯沿冷光、案边座次和门口仆从收住的脚步一起发沉,把这一步难堪代价摆得比训斥更清。" + ], + "vow_payment": [ + "贡院号舍的潮气像还粘在袖口,余澄一碰到那枚旧佩,春闱誓言的偿付就先从掌心发冷。", + "林绾没有追问,可回廊里那盏灯把他们当初说过的话一字一字照回眼前,像催他先认账。", + "纸页上的墨迹未干,徐师留下的题签却先把这一步誓言偿付压成了必须亲手交出的答案。" + ], + "debt_exchange": [ + "账册、婚帖和春闱名录同时摆在案上,旧账回潮不再像传闻,而像一笔要当面划清的债。", + "荣府管事把银票压平时,纸张摩擦的声音比责问更硬,逼余澄看见每一次顺从背后的交换。", + "门外更鼓短短一震,旧帖边缘的红印被灯照亮,把这一步旧账回潮从人情推成了明账。" + ], + "karma_ripening": [ + "春闱朱批被翻到那一页时,旧日每一次退让都像在灯下排成队,逼余澄亲眼看见因果回响。", + "檐下雨水一滴滴落进石缝,林绾没有开口,余澄却听见自己从前的选择正顺着回声回来。", + "徐师把笔搁下,墨痕还湿着,因果回响却已经从卷面漫到每个人的沉默里。" + ], + "misrecognition": [ + "回廊转角的脚步声先乱了一下,林绾误听的那半句还没澄清,误解升级就已经沿着门影铺开。", + "余澄想追上去时,花厅帘钩轻轻一晃,刚好把他没说完的解释挡在了另一边。", + "风把纸页翻过半张,露出的字却不是林绾以为的那句,误解升级便从这一点错位里开始发紧。" + ], + "mercy_vs_control": [ + "荣府书房的门闩落下时,庇护与控制的界线先在那声轻响里分开,谁都不能再把它说成善意。", + "林绾把药盏推近半寸,余澄却看见盏底压着的不是体贴,而是要他照旧低头的规矩。", + "暖炉的火色照着窗纸,庇护与控制一同落在桌边那把空椅上,让每一句关心都带了重量。" + ] + }, + "scene_hooks": { + "false_peace": [ + "这一步表面平静先停在这里,可真正要追上来的,是余澄接下春闱以后再也不能只靠顺从遮过去的那层真心。", + "席面虽然先散了,可名帖、茶盏和门第一起留下来的那笔账,下一次见面时还得有人自己认回去。", + "等下一次再开口时,谁也回不到还能把这一步表面平静轻轻说成理所当然的那一边。" + ], + "temptation": [ + "这一步试探先停在这里,可回廊里那点没散掉的风声会把余澄今天没敢认完的偏心一路留到下一次见面。", + "话虽然先落了地,可真正追上来的会是林绾到底还要不要逼他把后半句也认出来。", + "等下一次再开口时,谁也回不到还能把这一步试探轻轻绕成两全的那边。" + ], + "confession_window": [ + "这一步真话窗口先停在这里,可书房里那句被余澄亲口认下的怯意已经追到了他下一次做选择之前。", + "徐师没有替他把话说圆,所以下次再见时,真正难的只会是余澄还敢不敢把这句真话接着认完。", + "等下一次再开口时,谁也回不到还能把这一步真话窗口轻轻合上的那一边。" + ], + "truth_trial": [ + "这一步真相逼近先停在这里,可花园里那句“你到底是为谁去考”已经追到了余澄下一次抬眼之前。", + "话虽然先落了地,可真正不会散掉的,是林绾已经不肯再替他把最难听的那句留在猜测里。", + "等下一次再开口时,谁也回不到还能把这一步真相逼近轻轻拖回试探的那一边。" + ], + "mask_crack": [ + "裂口先停在屏风边,可徐师合上的那页卷宗会在下一次开口前继续追问余澄。", + "书房灯火虽然暗下去,真正留下来的却是余澄再也不能用门第替自己挡住的那句志气。", + "下次见面时,裂开的不会只是一句辩解,而是他到底肯不肯把选择拿到明处。" + ], + "humiliation": [ + "席面可以先散,花厅里那些压低的笑声却会把余澄下一次抬头逼得更难。", + "难堪代价先落在名帖旁,下一次回来时要追问的是他还肯不肯为体面继续让步。", + "等荣府门声再次响起,今日被看见的难堪不会自己变回顺从。" + ], + "vow_payment": [ + "誓言偿付先停在旧佩的冷意里,可春闱前那句承诺下一次会要余澄亲手兑现。", + "这一次话音落了,真正不散的是林绾听见那句承诺以后留给他的半步距离。", + "等卷面再被摊开时,誓言要追问的不会是他说了什么,而是他到底交出了什么。" + ], + "debt_exchange": [ + "旧账回潮先合在账册里,可那枚红印已经把下一次见面的价码留在了桌上。", + "人情可以先被收进袖里,明账却会在下一章逼余澄选清楚欠谁、还谁。", + "等更鼓再响时,这笔债不会再允许任何人把它说成一场体面安排。" + ], + "karma_ripening": [ + "因果回响先停在未干的墨痕里,可余澄从前每一次退让都会在下一次选择里排队回来。", + "徐师没有再劝,正因为如此,下次真正压上来的会是余澄自己种下的那道因。", + "等雨声再落进石缝,今日这层因果不会仍旧只是回声。" + ], + "misrecognition": [ + "误解升级先留在回廊转角,可林绾听错的那半句会在下一次见面时先到。", + "解释虽然没追上去,纸页翻出的那一行字却会逼余澄换一种方式把真话递过去。", + "等帘钩再次晃动时,今日的错位不会再给任何人装作没听见的余地。" + ], + "mercy_vs_control": [ + "庇护与控制先停在那只药盏旁,可下一次有人递来善意时,余澄必须先看清代价。", + "门闩落下的声音不会自己散掉,它会在下一章追问谁的保护其实也是束缚。", + "等暖炉火色再亮起来,今日这句关心不会仍旧只被当成关心。" + ] + }, + "scene_pressures": { + "humiliation": [ + "座次、名帖和压低的笑声会让人物先用动作接住羞辱,而不是再解释体面。", + "这一场必须让难堪落到杯沿、衣袖和旁人的视线里,逼选择当面发生。", + "羞辱不是背景噪声,而是把人物从顺从推向公开反应的压力源。" + ], + "vow_payment": [ + "誓言要通过旧佩、卷面或亲手交出的行动偿付,不能只停成口头承诺。", + "这一拍的压力来自承诺被兑现的方式,人物必须交出可见代价。", + "旧誓回潮时,场面需要让承诺变成手上动作和下一步选择。" + ], + "debt_exchange": [ + "旧账要落在账册、红印、银票或婚帖上,逼人物把人情拆成明账。", + "交换压力必须让谁欠谁、谁还谁变得可见,不能只靠心里想通。", + "这一步要把体面安排翻成价码,让人物当面承认债务方向。" + ], + "karma_ripening": [ + "因果要从卷面、朱批或旧选择的回声里成熟,逼人物看见自己种下的因。", + "这一拍的后果应当沿着过去动作回来,而不是抽象地说明命运。", + "让雨声、墨痕和停顿把旧因推回场面,人物必须用当前动作回应。" + ], + "misrecognition": [ + "误解要来自错听、错位的纸页或被门影截断的解释,逼人物换法澄清。", + "这一拍必须让偏差具体可见,不能只说彼此误会加深。", + "错位压力要把未说完的话留在场面里,推动下一次正面补偿。" + ], + "mercy_vs_control": [ + "庇护要带着药盏、门闩或空椅的控制痕迹,让善意与束缚同时可见。", + "这一拍的关心不能太轻,必须让人物看见保护背后的条件。", + "让照顾变成带价码的动作,逼人物区分被护住和被按住。" + ] + } } }, "narrative_style_pack": { @@ -3970,88 +4814,653 @@ "minimum_exchanges": 1 }, "emotion_actions": { - "policy_id": "jade_court_exam_default_action", + "policy_id": "jade_q03_pack_action", "action_map": { "false_peace": { "entry": [ - "桌上的器物轻轻一碰,谁都知道这一步已经走出去,很难再收回来。" + "名帖、茶盏和袖角轻响先动了一下,连表面平静都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步表面平静托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步表面平静压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步表面平静更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步表面平静再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步表面平静已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步表面平静留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步表面平静拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步表面平静慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步表面平静追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步表面平静根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步表面平静换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步表面平静越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步表面平静露了底。" + ] + }, + "temptation": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连试探都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步试探托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步试探压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步试探更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步试探再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步试探已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步试探留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步试探拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步试探慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步试探追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步试探根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步试探换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步试探越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步试探露了底。" + ] + }, + "confession_window": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连真话窗口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步真话窗口托到了眼前。" ], "pressure": [ - "最细小的抬眼和换气都带上了掂量,像谁先多动一下,谁就会先露底。" + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步真话窗口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步真话窗口更清。" ], "pivot": [ - "那一点极轻的停顿和改口,让场面从还能周旋,变成了不得不选边。" + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步真话窗口再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步真话窗口已经成形。" ], "aftermath": [ - "人散得不快,沉默却先压了下来。" + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步真话窗口留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步真话窗口拖得更长。" ], "echo": [ - "越到后面,越能听见那些没说尽的话慢慢回身。" + "越到后面,越能听见回廊风、书页和灯火余波把这一步真话窗口慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步真话窗口追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步真话窗口根本没结束。" ], "repeat": [ - "动作并不大,可谁都知道事情已经换了味道。" + "动作并不大,可灯影和名帖边角已经说明这一步真话窗口换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步真话窗口越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步真话窗口露了底。" ] }, "truth_trial": { "entry": [ - "先动的不是声音,而是视线和手指那一点收紧。" + "名帖、茶盏和袖角轻响先动了一下,连真相逼近都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步真相逼近托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步真相逼近压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步真相逼近更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步真相逼近再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步真相逼近已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步真相逼近留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步真相逼近拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步真相逼近慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步真相逼近追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步真相逼近根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步真相逼近换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步真相逼近越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步真相逼近露了底。" + ] + }, + "mask_crack": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连裂口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步裂口托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步裂口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步裂口更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步裂口再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步裂口已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步裂口留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步裂口拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步裂口慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步裂口追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步裂口根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步裂口换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步裂口越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步裂口露了底。" + ] + }, + "humiliation": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连难堪代价都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步难堪代价托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步难堪代价压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步难堪代价更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步难堪代价再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步难堪代价已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步难堪代价留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步难堪代价拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步难堪代价慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步难堪代价追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步难堪代价根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步难堪代价换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步难堪代价越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步难堪代价露了底。" + ] + }, + "vow_payment": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连誓言偿付都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步誓言偿付托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步誓言偿付压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步誓言偿付更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步誓言偿付再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步誓言偿付已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步誓言偿付留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步誓言偿付拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步誓言偿付慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步誓言偿付追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步誓言偿付根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步誓言偿付换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步誓言偿付越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步誓言偿付露了底。" + ] + }, + "debt_exchange": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连旧账回潮都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步旧账回潮托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步旧账回潮压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步旧账回潮更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步旧账回潮再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步旧账回潮已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步旧账回潮留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步旧账回潮拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步旧账回潮慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步旧账回潮追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步旧账回潮根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步旧账回潮换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步旧账回潮越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步旧账回潮露了底。" + ] + }, + "karma_ripening": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连因果回响都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步因果回响托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步因果回响压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步因果回响更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步因果回响再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步因果回响已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步因果回响留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步因果回响拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步因果回响慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步因果回响追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步因果回响根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步因果回响换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步因果回响越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步因果回响露了底。" + ] + }, + "misrecognition": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连误解升级都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步误解升级托到了眼前。" ], "pressure": [ - "杯沿上那一点冷光轻轻一闪,连呼吸都像被逼慢了半拍。" + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步误解升级压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步误解升级更清。" ], "pivot": [ - "风从门缝里钻进来,把场里的沉默一下子吹偏了方向。" + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步误解升级再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步误解升级已经成形。" ], "aftermath": [ - "茶香已经淡了,场里的气却还迟迟不肯散开。" + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步误解升级留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步误解升级拖得更长。" ], "echo": [ - "等人散尽以后,连空下来的位置都还像留着刚才那句重话。" + "越到后面,越能听见回廊风、书页和灯火余波把这一步误解升级慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步误解升级追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步误解升级根本没结束。" ], "repeat": [ - "动作不大,可谁都知道这句真话已经绕不过去了。" + "动作并不大,可灯影和名帖边角已经说明这一步误解升级换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步误解升级越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步误解升级露了底。" + ] + }, + "mercy_vs_control": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连庇护与控制都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步庇护与控制托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步庇护与控制压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步庇护与控制更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步庇护与控制再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步庇护与控制已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步庇护与控制留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步庇护与控制拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步庇护与控制慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步庇护与控制追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步庇护与控制根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步庇护与控制换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步庇护与控制越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步庇护与控制露了底。" ] } } }, "sensory_grounding": { - "policy_id": "jade_court_exam_default_sensory", + "policy_id": "jade_q03_pack_sensory", "location_slots": { - "generic": { + "荣府": { + "atmosphere": [ + "荣府里并不安静,门影、灯火和檀香一起把分寸压得太紧。", + "荣府的空气像被规矩熬得发沉,连长廊风和衣摆都不敢轻易乱动。", + "灯火顺着荣府的门框和窗纸一寸寸压下来,像先替家门把人心困在原地。" + ], + "detail": [ + "灯影压在窗纸、茶盏和门框边,檀香落到案角纸页与衣袖上,长廊风轻轻擦过石阶发响", + "门影落在衣摆和席边案沿上,茶气混着香灰贴住袖口,窗纸后那点灯火把纸页照得发卷", + "檀香顺着门框、窗纸和衣袖散下来,灯影落在茶盏与案角边,风把长廊上的脚步回声拖得更长" + ], + "repeat_detail": [ + "越到后面,荣府里的灯火、檀香和门影越像一起把那句真话压回每个人心上。", + "等人散开以后,荣府的长廊风、茶气和纸页轻响反而把后半句留得更沉。", + "荣府先静下来,可真正不肯散的是灯影、规矩和衣角上的那点冷。" + ] + }, + "花厅": { + "atmosphere": [ + "花厅里檀香未散,窗纸、灯火和席间视线都亮得过分。", + "花厅的气比屋外更紧,连茶盏边的冷光都像在替人问话。", + "花厅灯火把门框、桌案和衣摆一并照得太清,谁都难再装稳。" + ], + "detail": [ + "杯沿冷光映在窗纸、衣袖和席边案沿,檀香贴着茶气和发梢散开,门边风轻轻掠过纸页", + "花厅灯影落在门框、茶盏和衣摆上,席间器物轻轻碰到案沿,窗外风把香灰吹得发响", + "檀香混着茶气压在袖口和发边,花厅门影照到纸页与桌角,杯沿和灯火一起把目光衬得更冷" + ], + "repeat_detail": [ + "越到后面,花厅里的灯影、茶气和席间回声越像把那句没认完的话一遍遍推回来。", + "等檀香再低一层时,花厅的门影、窗纸和衣袖轻响反而把后果照得更清。", + "花厅看似稳住了,真正不肯散的是灯火、冷光和席间静气。" + ] + }, + "书房": { "atmosphere": [ - "generic里并不安静,连空气都像压着一句没说完的话。" + "书房里纸页、旧墨和灯火一起发沉,像每一句真话都会留下痕。", + "书房并不大,灯影、墨香和案边冷意却把退路都照得太薄。", + "旧墨味贴着书房的窗纸和桌案走,连呼吸都像先沉了一格。" ], "detail": [ - "generic里的光线、器物和衣袖摩擦声,都把场里的情绪衬得更清。" + "灯火压在纸页、案沿和袖口边,旧墨混着茶气贴到发梢,窗纸后风声把书页吹得轻响", + "案上纸页、笔架和茶盏一起发冷,灯影落在衣摆与门框边,墨香和窗外风把桌角压得更静", + "旧墨味落在纸页、书案和袖口上,灯火沿着门框与窗纸爬过去,翻页声轻轻擦过茶盏边缘" ], "repeat_detail": [ - "generic里最轻的一点动静,反而把话里的分量又压重了一层。" + "越到后面,书房里的纸页、旧墨和灯影越像把那句没认完的道理压得更实。", + "等翻页声停下以后,书房的茶气、窗纸和案边冷意反而把余波留得更久。", + "书房先静下来,可真正不肯散的是墨香、灯火和那层难以承认的亏欠。" + ] + }, + "回廊": { + "atmosphere": [ + "回廊里的风比屋内更直,灯影、木板和衣摆一起把心思吹得发响。", + "回廊没有真正的静,风、灯和脚步回声都在替每一句话续后果。", + "回廊风从木栏和灯下掠过时,连抬眼都像没法再装作平常。" + ], + "detail": [ + "灯影落在木栏、衣摆和鞋尖边,回廊风吹过袖口和发梢,木板回声沿着门边拖长", + "回廊灯火压在栏杆、袖角和地板上,风从木缝里擦过发出轻响,脚步把回声踩在门影边", + "木栏冷影落在鞋边和衣摆上,回廊风掀起袖口与发梢,灯火把木板和门框照得发亮" + ], + "repeat_detail": [ + "越往后,回廊里的风、灯影和木板回声越像把那句真话拖得更长。", + "等脚步声散远以后,回廊的木栏、门影和衣角轻响反而把余波留得更近。", + "回廊先空下来,可真正不肯散的是风声、灯火和那层还没认完的心意。" + ] + }, + "花园": { + "atmosphere": [ + "花园里的湿气贴着衣袖和发梢,灯、枝影和风声一起把试探照得更冷。", + "花园深处不见太亮的灯,枝影、潮气和石径回声反而把真相逼得更近。", + "风从花园深处卷过来,带着叶影、灯色和薄凉,把每句试探都衬得更硬。" + ], + "detail": [ + "枝影落在石径、衣摆和袖口边,灯色压在叶面与门影上,风吹过花叶和鞋底发出轻响", + "花园湿气贴着发梢与衣角,石径反着灯色照到鞋尖边,枝影和风把袖口压得发凉", + "叶影压在石径、衣摆和手背上,花园风擦过门边与花枝,灯色从湿叶和窗纸间漏下来" + ], + "repeat_detail": [ + "越到后面,花园里的叶影、灯色和石径回声越像把误会磨得更清。", + "等风再掠过花枝时,花园的湿气、枝影和衣角轻响反而把心事留得更近。", + "花园先安静下来,可真正不肯散的是叶影、风声和那句没认完的话。" + ] + }, + "考棚": { + "atmosphere": [ + "考棚里闷得厉害,纸页、号板和呼吸声都像在替人审问。", + "考棚没有真正的静,纸、墨和木板细响一起把羞耻压到了眼前。", + "闷热的空气贴着考棚的窗缝和衣袖,让每一次落笔都像在认账。" + ], + "detail": [ + "卷面纸页压在号板、袖口和案边上,墨香混着汗气贴着发梢,木板轻响沿着脚边拖开", + "考棚里纸页、笔尖和号板一起发干,闷热空气贴住衣摆与手背,窗缝风把灰屑吹到案边", + "卷面边角、墨迹和衣袖一起贴在案沿边,木板回声轻轻碰到鞋尖,考棚里那点风把纸页吹得发颤" + ], + "repeat_detail": [ + "越到后面,考棚里的纸页、墨迹和木板回声越像把那层难堪压得更实。", + "等落笔声停下来以后,考棚里的汗气、纸响和号板冷意反而把后果留得更长。", + "考棚先闷住了,可真正不肯散的是纸页、呼吸和那句不敢认的真心。" + ] + }, + "家宴": { + "atmosphere": [ + "家宴上的灯火太亮,杯盏、衣影和满桌视线一起把退路照得很窄。", + "家宴看着热闹,真正压人的却是灯火、酒气和每一道目光都没有移开。", + "席间风声不大,杯盏、灯影和人声却把那句真话烘得更烫。" + ], + "detail": [ + "杯盏冷光映在灯火、衣袖和案边上,酒气贴着发梢与袖口散开,席间器物轻轻碰到桌沿", + "家宴灯影照在酒盏、门框和衣摆上,人声压着茶气和香气走过桌边,杯沿和桌案一起发出轻响", + "酒气混着灯火压在衣角和发梢上,席间碗盏映到袖口与桌边,门外风把灯影和人声拖长" + ], + "repeat_detail": [ + "越到后面,家宴上的灯火、酒气和杯盏轻响越像把那句拒绝一遍遍推回人群里。", + "等人声一落,家宴的门影、酒气和桌边冷光反而把后果照得更硬。", + "家宴看似散开了,真正不肯散的是灯火、目光和那层被当众认下的代价。" + ] + }, + "内室": { + "atmosphere": [ + "内室里灯火压得低,帘影、木案和香气一起把话逼得只剩真心可认。", + "内室比外头更静,帘子、灯芯和桌案冷影都像在问这笔交易到底值不值。", + "香气困在内室里不肯散,连灯影、门框和衣摆都显得过分贴近。" + ], + "detail": [ + "帘影压在灯芯、桌案和衣袖边,香气贴着发梢与门框散开,指尖轻触木案发出极轻响", + "内室灯火映到帘角、衣摆和手背上,香灰落到木案与纸页边,门外风只轻轻碰了一下窗纸", + "帘角、灯影和桌案冷光一起压在衣袖上,香气混着纸页气息贴着发梢,门框边的风把窗纸吹得微微发响" + ], + "repeat_detail": [ + "越到后面,内室里的灯火、帘影和香气越像把那桩交易背后的控制照得更清。", + "等香气再沉下一层时,内室的桌案、门影和衣角轻响反而把后半句留得更久。", + "内室先安静下来,可真正不肯散的是灯影、帘子和那层说成保护的控制。" + ] + }, + "渡口": { + "atmosphere": [ + "渡口的风把水气一阵阵推过来,船影、灯火和衣摆一起显得没法站稳。", + "渡口比城里更空,水声、风和脚边石板都像会把每一句话重新送回来。", + "水气贴着渡口的栏杆和衣袖往上爬,连呼吸都像先凉了一层。" + ], + "detail": [ + "水光映在栏杆、衣摆和鞋尖边,风吹过船绳与发梢发出轻响,渡口灯火压在石板和袖口上", + "船影落在栏杆、衣角和水面边,风把渡口灯火和发梢一起掀起来,鞋底擦过石板拖出短促回声", + "水气贴着栏杆、袖口和发边,渡口灯影照到船绳与石板,风从水面把衣摆和影子一起推斜" + ], + "repeat_detail": [ + "越到后面,渡口上的水光、风声和石板回响越像把那句真话重新送回来。", + "等船影远下去以后,渡口的栏杆、灯火和衣角凉意反而把余波拖得更长。", + "渡口先空下来,可真正不肯散的是水声、风和那句还没认完的选择。" ] } }, "generic_slots": { "atmosphere": [ - "场里并不安静,连空气都像在替谁压住一口没说完的话。" + "这府里的空气总是压着门第、风声和没说完的话。", + "灯火、门影和呼吸一起发沉,像谁都别想把真话干净地绕过去。", + "真正先发紧的不是声量,而是檐下风、茶气和每个人眼里的那点分寸。" ], "detail": [ - "细小的声响和光线变化,把场里的情绪压得更清了一层。" + "灯影、纸页、茶盏和衣袖一起发亮,门边风把香气和发梢轻轻掀起", + "门影压住桌案、袖口和鞋边,茶气、旧墨和灯火顺着纸页慢慢走", + "杯沿冷光落在窗纸、衣摆和手背边,风从檐下掠过门框与纸页发响" ], "repeat_detail": [ - "越到后面,越能听见那些没说尽的话慢慢回身。" + "等沉默拖长以后,连灯火、门影、茶气和纸页轻响都像在替后果追账。", + "越到后面,屋里最轻的一点风声和杯盏回响反而把情绪压得更实。", + "场面看似静了,真正不肯散的是灯影、香气和那句还没认完的话。" ] } }, "scene_realization": { - "contract_id": "jade_court_exam_scene_realizer", - "dialogue_policy_id": "jade_court_exam_dialogue", - "default_voice_profile_id": "default", - "default_cadence_id": "default", - "default_pressure_style_id": "default", - "default_emotion_action_policy_id": "default", - "default_sensory_policy_id": "default", - "narrative_style_pack_id": "default", - "scene_openings": {}, - "scene_hooks": {}, + "contract_id": "jade_q03_pack_scene_realization", + "scene_openings": { + "false_peace": [ + "花厅与书房里的灯火、门影和檀香先压下来,像连这一步表面平静都被提前照到了明处。", + "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步表面平静只让它再也没法被带过去。", + "檐下风与墨香里先变的不是声量,而是那层谁都不肯先认的表面平静忽然有了形。" + ], + "temptation": [ + "花厅与书房里的灯火、门影和檀香先压下来,像连这一步试探都被提前照到了明处。", + "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步试探只让它再也没法被带过去。", + "檐下风与墨香里先变的不是声量,而是那层谁都不肯先认的试探忽然有了形。" + ], + "confession_window": [ + "花厅与书房里的灯火、门影和檀香先压下来,像连这一步真话窗口都被提前照到了明处。", + "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步真话窗口只让它再也没法被带过去。", + "檐下风与墨香里先变的不是声量,而是那层谁都不肯先认的真话窗口忽然有了形。" + ], + "truth_trial": [ + "花厅与书房里的灯火、门影和檀香先压下来,像连这一步真相逼近都被提前照到了明处。", + "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步真相逼近只让它再也没法被带过去。", + "檐下风与墨香里先变的不是声量,而是那层谁都不肯先认的真相逼近忽然有了形。" + ], + "mask_crack": [ + "花厅与书房里的灯火、门影和檀香先压下来,像连这一步裂口都被提前照到了明处。", + "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步裂口只让它再也没法被带过去。", + "檐下风与墨香里先变的不是声量,而是那层谁都不肯先认的裂口忽然有了形。" + ], + "humiliation": [ + "花厅与书房里的灯火、门影和檀香先压下来,像连这一步难堪代价都被提前照到了明处。", + "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步难堪代价只让它再也没法被带过去。", + "檐下风与墨香里先变的不是声量,而是那层谁都不肯先认的难堪代价忽然有了形。" + ], + "vow_payment": [ + "花厅与书房里的灯火、门影和檀香先压下来,像连这一步誓言偿付都被提前照到了明处。", + "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步誓言偿付只让它再也没法被带过去。", + "檐下风与墨香里先变的不是声量,而是那层谁都不肯先认的誓言偿付忽然有了形。" + ], + "debt_exchange": [ + "花厅与书房里的灯火、门影和檀香先压下来,像连这一步旧账回潮都被提前照到了明处。", + "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步旧账回潮只让它再也没法被带过去。", + "檐下风与墨香里先变的不是声量,而是那层谁都不肯先认的旧账回潮忽然有了形。" + ], + "karma_ripening": [ + "花厅与书房里的灯火、门影和檀香先压下来,像连这一步因果回响都被提前照到了明处。", + "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步因果回响只让它再也没法被带过去。", + "檐下风与墨香里先变的不是声量,而是那层谁都不肯先认的因果回响忽然有了形。" + ], + "misrecognition": [ + "花厅与书房里的灯火、门影和檀香先压下来,像连这一步误解升级都被提前照到了明处。", + "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步误解升级只让它再也没法被带过去。", + "檐下风与墨香里先变的不是声量,而是那层谁都不肯先认的误解升级忽然有了形。" + ], + "mercy_vs_control": [ + "花厅与书房里的灯火、门影和檀香先压下来,像连这一步庇护与控制都被提前照到了明处。", + "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步庇护与控制只让它再也没法被带过去。", + "檐下风与墨香里先变的不是声量,而是那层谁都不肯先认的庇护与控制忽然有了形。" + ] + }, + "scene_hooks": { + "false_peace": [ + "这一步表面平静先停在这里,可真正要追上来的,是门第、真心与迟来的认账。", + "话虽然先落了地,下一次见面时还得补完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步表面平静轻轻带过去的那边。" + ], + "temptation": [ + "这一步试探先停在这里,可真正要追上来的,是门第、真心与迟来的认账。", + "话虽然先落了地,下一次见面时还得补完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步试探轻轻带过去的那边。" + ], + "confession_window": [ + "这一步真话窗口先停在这里,可真正要追上来的,是门第、真心与迟来的认账。", + "话虽然先落了地,下一次见面时还得补完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步真话窗口轻轻带过去的那边。" + ], + "truth_trial": [ + "这一步真相逼近先停在这里,可真正要追上来的,是门第、真心与迟来的认账。", + "话虽然先落了地,下一次见面时还得补完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步真相逼近轻轻带过去的那边。" + ], + "mask_crack": [ + "这一步裂口先停在这里,可真正要追上来的,是门第、真心与迟来的认账。", + "话虽然先落了地,下一次见面时还得补完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步裂口轻轻带过去的那边。" + ], + "humiliation": [ + "这一步难堪代价先停在这里,可真正要追上来的,是门第、真心与迟来的认账。", + "话虽然先落了地,下一次见面时还得补完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步难堪代价轻轻带过去的那边。" + ], + "vow_payment": [ + "这一步誓言偿付先停在这里,可真正要追上来的,是门第、真心与迟来的认账。", + "话虽然先落了地,下一次见面时还得补完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步誓言偿付轻轻带过去的那边。" + ], + "debt_exchange": [ + "这一步旧账回潮先停在这里,可真正要追上来的,是门第、真心与迟来的认账。", + "话虽然先落了地,下一次见面时还得补完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步旧账回潮轻轻带过去的那边。" + ], + "karma_ripening": [ + "这一步因果回响先停在这里,可真正要追上来的,是门第、真心与迟来的认账。", + "话虽然先落了地,下一次见面时还得补完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步因果回响轻轻带过去的那边。" + ], + "misrecognition": [ + "这一步误解升级先停在这里,可真正要追上来的,是门第、真心与迟来的认账。", + "话虽然先落了地,下一次见面时还得补完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步误解升级轻轻带过去的那边。" + ], + "mercy_vs_control": [ + "这一步庇护与控制先停在这里,可真正要追上来的,是门第、真心与迟来的认账。", + "话虽然先落了地,下一次见面时还得补完的那句真话却不会自己散掉。", + "等下一次再开口时,谁也回不到还能把这一步庇护与控制轻轻带过去的那边。" + ] + }, "scene_pressures": {} } } diff --git a/examples/worldpacks/jade_court_romance_pack.json b/examples/worldpacks/jade_court_romance_pack.json index d07d1a9..f03d4ae 100644 --- a/examples/worldpacks/jade_court_romance_pack.json +++ b/examples/worldpacks/jade_court_romance_pack.json @@ -1261,7 +1261,94 @@ "consequence_delay_hint": 2, "location": "花厅", "convergence_key": "", - "metadata": {} + "metadata": { + "continuation_blueprints": [ + { + "blueprint_id": "accept_exam_nomination::truth_trial", + "scene_function": "truth_trial", + "location": "花厅", + "actors": [ + "lady_rong", + "yu_cheng" + ], + "tags": [ + "duty", + "reputation", + "truth" + ], + "title": "荣老太君顺着体面把最难认的那句逼到余澄面前", + "summary": "席面还没散尽,荣老太君已经借一句看似体面的安排逼余澄承认:他答应春闱究竟是为了家门,还是因为早就不敢替自己选路。", + "agency_affordances": [ + "duty", + "truth", + "selfhood" + ] + }, + { + "blueprint_id": "accept_exam_nomination::confession_window", + "scene_function": "confession_window", + "location": "书房", + "actors": [ + "yu_cheng", + "tutor_xu" + ], + "tags": [ + "truth", + "selfhood", + "reputation" + ], + "title": "徐师在书房里把那句不能再装糊涂的话留给余澄自己来认", + "summary": "花厅散后,余澄走进书房,徐师没有安慰,只把那句最难听的话放在案上,让他承认自己怕的不是考试,而是活成门第替他写好的样子。", + "agency_affordances": [ + "truth", + "selfhood", + "continue_story" + ] + }, + { + "blueprint_id": "accept_exam_nomination::temptation", + "scene_function": "temptation", + "location": "回廊", + "actors": [ + "yu_cheng", + "lin_wan" + ], + "tags": [ + "love", + "truth", + "duty" + ], + "title": "林绾在回廊里递来一条看似两全却更伤人的路", + "summary": "回廊风一过,林绾先替余澄把那条最容易自欺的退路说了出来:若只要继续顺着春闱往下走,就还能把真心和家门暂时都护得体面一些。", + "agency_affordances": [ + "love", + "duty", + "truth" + ] + }, + { + "blueprint_id": "accept_exam_nomination::debt_exchange", + "scene_function": "debt_exchange", + "location": "荣府", + "actors": [ + "yu_cheng", + "lady_rong" + ], + "tags": [ + "duty", + "family", + "reputation" + ], + "title": "答应春闱之后,那笔门第顺从债立刻开始往余澄身上结算", + "summary": "不过一顿饭的工夫,荣府里已经有人开始替余澄盘算要用什么样的沉默和顺从来替整个家门把这桩安排继续遮圆,也让他第一次看见这笔债会怎么一点点算到自己头上。", + "agency_affordances": [ + "duty", + "reputation", + "continue_story" + ] + } + ] + } }, { "event_id": "secret_meet_lin_wan", @@ -1397,7 +1484,71 @@ "consequence_delay_hint": 3, "location": "回廊", "convergence_key": "", - "metadata": {} + "metadata": { + "continuation_blueprints": [ + { + "blueprint_id": "secret_meet_lin_wan::truth_trial", + "scene_function": "truth_trial", + "location": "回廊", + "title": "回廊里那句试探终于被追成了真正的逼问", + "summary": "林绾不再陪余澄绕路,回廊里最难听的那句终于被逼到了最前面。", + "tags": [ + "love", + "truth", + "romance" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "secret_meet_lin_wan__promise" + ], + "duty_allowlist": [ + "advance_plot", + "deliver_climax", + "resolve_promise" + ], + "phase_allowlist": [ + "midpoint", + "crisis", + "climax" + ], + "tension_delta": 0.14 + }, + { + "blueprint_id": "secret_meet_lin_wan::confession_window", + "scene_function": "confession_window", + "location": "花园", + "title": "那次试探没有白过去,终于出现了一个能把真心说全的窗口", + "summary": "花园里的风不替任何人遮掩,反而逼得余澄把一直只敢认一半的心意再往前送。", + "tags": [ + "love", + "truth", + "romance" + ], + "agency_affordances": [ + "truth", + "confession", + "continue_story" + ], + "promises_close": [ + "secret_meet_lin_wan__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ], + "tension_delta": 0.11 + } + ] + } }, { "event_id": "confide_in_tutor_xu", @@ -1515,7 +1666,69 @@ "consequence_delay_hint": 2, "location": "书房", "convergence_key": "", - "metadata": {} + "metadata": { + "continuation_blueprints": [ + { + "blueprint_id": "confide_in_tutor_xu::karma_ripening", + "scene_function": "karma_ripening", + "location": "书房", + "title": "书房里那句怀疑终于开始带着后果回潮", + "summary": "徐师听见的那半句犹疑没有白落下去,它开始逼余澄正视后面的代价。", + "tags": [ + "truth", + "fate", + "reputation" + ], + "agency_affordances": [ + "memory", + "truth", + "continue_story" + ], + "promises_close": [ + "confide_in_tutor_xu__promise" + ], + "duty_allowlist": [ + "expand_world", + "resolve_promise" + ], + "phase_allowlist": [ + "aftermath", + "crisis", + "climax" + ], + "tension_delta": 0.11 + }, + { + "blueprint_id": "confide_in_tutor_xu::debt_exchange", + "scene_function": "debt_exchange", + "location": "书房", + "title": "说出口的那句怀疑终于开始往余澄自己身上结算", + "summary": "书房里那句“我究竟该成为什么样的人”不再只是道理,而开始变成必须偿付的选择。", + "tags": [ + "truth", + "debt", + "reputation" + ], + "agency_affordances": [ + "truth", + "responsibility", + "continue_story" + ], + "promises_close": [ + "confide_in_tutor_xu__promise" + ], + "duty_allowlist": [ + "resolve_promise", + "advance_plot" + ], + "phase_allowlist": [ + "crisis", + "aftermath" + ], + "tension_delta": 0.12 + } + ] + } }, { "event_id": "lin_wan_asks_for_truth", @@ -1635,7 +1848,71 @@ "consequence_delay_hint": 1, "location": "花园", "convergence_key": "", - "metadata": {} + "metadata": { + "continuation_blueprints": [ + { + "blueprint_id": "lin_wan_asks_for_truth::truth_trial", + "scene_function": "truth_trial", + "location": "花园", + "title": "花园里那句逼问终于不再允许任何人只留半句真话", + "summary": "林绾追问过的那句话终于从试探变成了不能回避的正面选择。", + "tags": [ + "truth", + "love", + "romance" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "lin_wan_asks_for_truth__promise" + ], + "duty_allowlist": [ + "advance_plot", + "deliver_climax", + "resolve_promise" + ], + "phase_allowlist": [ + "midpoint", + "crisis", + "climax" + ], + "tension_delta": 0.15 + }, + { + "blueprint_id": "lin_wan_asks_for_truth::confession_window", + "scene_function": "confession_window", + "location": "回廊", + "title": "逼问过后,终于出现了一个不能再把心意藏回去的窗口", + "summary": "回廊里的风先收住了所有借口,逼得余澄把那句原本想拖到以后再说的话提前认出来。", + "tags": [ + "love", + "truth", + "romance" + ], + "agency_affordances": [ + "truth", + "confession", + "continue_story" + ], + "promises_close": [ + "lin_wan_asks_for_truth__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ], + "tension_delta": 0.11 + } + ] + } }, { "event_id": "rigged_recommendation_exposed", @@ -2111,7 +2388,69 @@ "consequence_delay_hint": 2, "location": "花厅", "convergence_key": "", - "metadata": {} + "metadata": { + "continuation_blueprints": [ + { + "blueprint_id": "protect_family_and_take_blame::debt_exchange", + "scene_function": "debt_exchange", + "location": "花厅", + "title": "揽下污名以后,真正该偿付的那一层旧账开始回到余澄自己身上", + "summary": "花厅里的风没有替他把污名吹散,反而让那笔替旁人背下来的账开始真正结算。", + "tags": [ + "debt", + "reputation", + "love" + ], + "agency_affordances": [ + "responsibility", + "truth", + "continue_story" + ], + "promises_close": [ + "protect_family_and_take_blame__promise" + ], + "duty_allowlist": [ + "resolve_promise", + "advance_plot" + ], + "phase_allowlist": [ + "crisis", + "aftermath" + ], + "tension_delta": 0.12 + }, + { + "blueprint_id": "protect_family_and_take_blame::confession_window", + "scene_function": "confession_window", + "location": "回廊", + "title": "污名背上身以后,终于出现了一个不靠体面维持关系的窗口", + "summary": "回廊里的静把污名和真心一起压近,逼得那句最怕说出口的话不得不提前认下来。", + "tags": [ + "love", + "truth", + "romance" + ], + "agency_affordances": [ + "truth", + "confession", + "continue_story" + ], + "promises_close": [ + "protect_family_and_take_blame__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ], + "tension_delta": 0.1 + } + ] + } }, { "event_id": "tutor_xu_reveals_burned_memorial", @@ -2230,7 +2569,69 @@ "consequence_delay_hint": 1, "location": "书房", "convergence_key": "", - "metadata": {} + "metadata": { + "continuation_blueprints": [ + { + "blueprint_id": "tutor_xu_reveals_burned_memorial::karma_ripening", + "scene_function": "karma_ripening", + "location": "书房", + "title": "旧荐书翻出来以后,那笔后果终于开始真正回潮", + "summary": "被烧过一角的旧荐书不再只是证据,而开始把更完整的因果推回眼前。", + "tags": [ + "truth", + "fate", + "reputation" + ], + "agency_affordances": [ + "memory", + "truth", + "continue_story" + ], + "promises_close": [ + "tutor_xu_reveals_burned_memorial__promise" + ], + "duty_allowlist": [ + "expand_world", + "resolve_promise" + ], + "phase_allowlist": [ + "aftermath", + "crisis", + "climax" + ], + "tension_delta": 0.12 + }, + { + "blueprint_id": "tutor_xu_reveals_burned_memorial::debt_exchange", + "scene_function": "debt_exchange", + "location": "书房", + "title": "旧荐书背后的那笔账终于开始有人认回去", + "summary": "书房里的灯火把那封旧荐书照得太亮,逼得余澄不能再只把它当成旧闻,而得认下它现在落到自己身上的账。", + "tags": [ + "truth", + "debt", + "reputation" + ], + "agency_affordances": [ + "truth", + "responsibility", + "continue_story" + ], + "promises_close": [ + "tutor_xu_reveals_burned_memorial__promise" + ], + "duty_allowlist": [ + "resolve_promise", + "advance_plot" + ], + "phase_allowlist": [ + "crisis", + "aftermath" + ], + "tension_delta": 0.12 + } + ] + } }, { "event_id": "lin_wan_reads_the_memorial", @@ -3496,19 +3897,34 @@ "hesitation_style": "把更难听的话先咽半口", "direct_address_style": "先看对方,再把话送出去", "opening_style": [ - "我知道这一步迟早得走,只是没想到会压得这样快。" + "我知道这一步迟早得走,只是没想到会压得这样快。", + "话既然已经推到我面前,我总不能再装作自己还有别的退路。", + "我不是没想过逃,只是到今天才知道真正追上来的从来不是考试本身。" ], "pressure_style": [ - "你若真要逼我回答,我也不能再把自己缩回去。" + "你若真要逼我回答,我也不能再把自己缩回去。", + "我怕的不是你听见真话,是你听见以后才知道我一直有多怯。", + "再把这句吞回去,我就真的只剩别人替我写好的那个余澄了。" ], "pivot_style": [ - "真正难的不是选路,而是承认自己早就被逼到了墙角。" + "真正难的不是选路,而是承认自己早就被逼到了墙角。", + "若我还把这件事说成体面,那才是真的辜负了今天走到这里的每一个人。", + "我不是不会选,只是一直不敢承认自己想活成谁。" ], "aftermath_style": [ - "话虽然停了,可谁都知道,这事不会就这样过去。" + "话虽然停了,可谁都知道,这事不会就这样过去。", + "我先把这句认下来,后面的难看也该轮到我自己去接。", + "事情既然已经到了这里,就别再让别人替我收那层残局。" ], "echo_style": [ - "等风声追上来时,我总得先替自己说一句真话。" + "等风声追上来时,我总得先替自己说一句真话。", + "下次再见时,我该带来的不是更圆的道理,而是完整的答案。", + "这回先停住,可后面追上来的还是我没认完的那层亏欠。" + ], + "signature_replies": [ + "这句话既然出口,我就不再往回收。", + "我先把这一层认下,剩下的你们不必替我圆。", + "该我自己背的那部分,我不会再交给门第和规矩替我背。" ] }, "lin_wan": { @@ -3521,19 +3937,34 @@ "hesitation_style": "把更难听的话先咽半口", "direct_address_style": "先看对方,再把话送出去", "opening_style": [ - "你若只是来试探,我宁可你现在就别开口。" + "你若只是来试探,我宁可你现在就别开口。", + "我不是来陪你圆这层话的,我是来问你今天到底还准不准备认。", + "你既然站到我面前,就别再指望我把最重的那句替你绕过去。" ], "pressure_style": [ - "你总替旁人找退路,可你自己的心,到底准备放在哪里?" + "你总替旁人找退路,可你自己的心,到底准备放在哪里?", + "你若还想拿家门和体面挡在前头,那就别怪我把真正的问题问到最难听。", + "我不怕答案难听,我只怕你又把我关在你肯认下的那层真话外面。" ], "pivot_style": [ - "你不是不会选,只是不肯承认自己已经偏向了谁。" + "你不是不会选,只是不肯承认自己已经偏向了谁。", + "要说就今天说透,别等这句话在下一次见面时坏得更难看。", + "你若再把我当成你能最后安抚的那个人,那我就只能先把这层误会撕开。" ], "aftermath_style": [ - "我不是催你给答案,只是不想看你再把沉默说成体面。" + "我不是催你给答案,只是不想看你再把沉默说成体面。", + "这句话先压在这里,不代表它会自己过去。", + "我可以先不追,可你总得自己学会把后半句带回来。" ], "echo_style": [ - "等你真肯来时,就别再只带着一半真话。" + "等你真肯来时,就别再只带着一半真话。", + "下一次见我时,你最好带着完整的心意,而不是更会伤人的体面。", + "这回先停在这里,可后面追上来的还是你欠我的那句认。" + ], + "signature_replies": [ + "既然已经说了,就别只给我半句。", + "你若真想护谁,就先别再把最难听的那句留给我自己猜。", + "这次我先把边界摆明,剩下的看你敢不敢自己过来认。" ] }, "lady_rong": { @@ -3546,19 +3977,34 @@ "hesitation_style": "把更难听的话先咽半口", "direct_address_style": "先看对方,再把话送出去", "opening_style": [ - "你既坐在这里,就该知道自己背后站着的不只你一个人。" + "你既坐在这里,就该知道自己背后站着的不只你一个人。", + "到了我这把年纪,最怕看的不是你顶嘴,是你装作还什么都不知道。", + "你若真要怪我,就先想清楚门楣塌下来时砸中的到底是谁。" ], "pressure_style": [ - "你可以怨我,可门楣真塌下来时,砸中的从来不止一个人。" + "你可以怨我,可门楣真塌下来时,砸中的从来不止一个人。", + "我不是不肯让你轻松,只是这府里的风一倒,压死的不会只是一张名帖。", + "你若要我承认心狠,也得先承认我替你挡下过多少风。" ], "pivot_style": [ - "你真以为把真相摊开,所有人就都能活得更轻一些?" + "你真以为把真相摊开,所有人就都能活得更轻一些?", + "该挑明的时候,往后拖一寸,伤的都只会是整个家门的骨头。", + "你若以为只要认了真心就能不认后果,那才真是把我这些年看轻了。" ], "aftermath_style": [ - "今日这句话你尽可以记恨,可等风声压下来时,你自然会明白。" + "今日这句话你尽可以记恨,可等风声压下来时,你自然会明白。", + "我先把这层狠背下来,回头你就知道我不是在替自己留路。", + "人可以先散,家门和账却都会在风里把人重新召回来。" ], "echo_style": [ - "等这阵风过后,你若还只看见我的狠,那也随你。" + "等这阵风过后,你若还只看见我的狠,那也随你。", + "下次你再来见我,最好已经想清楚门第和真心谁更会要人的命。", + "这回我先收声,可后面追上来的,还是你今天没肯认完的那层责任。" + ], + "signature_replies": [ + "话既然到了台面上,就该按台面上的规矩说。", + "你若连自己都不肯承认,又拿什么撑住这一局。", + "等你再回话时,记得把分寸和真话一起带来。" ] }, "tutor_xu": { @@ -3571,19 +4017,34 @@ "hesitation_style": "把更难听的话先咽半口", "direct_address_style": "先看对方,再把话送出去", "opening_style": [ - "肯把话说出来,总比把自己活成规矩强。" + "肯把话说出来,总比把自己活成规矩强。", + "你既然愿意进这间书房,就别指望我还会替你把最重的那句藏起来。", + "读书人最怕的从来不是题难,而是明明心里有裂口还要装平。" ], "pressure_style": [ - "旁人替你铺再多路,也不能替你决定这一步要不要昧着心走。" + "旁人替你铺再多路,也不能替你决定这一步要不要昧着心走。", + "你若连自己都不肯问到底,我再替你讲一百遍道理也没用。", + "把书读明白不难,难的是你肯不肯承认自己现在站错了哪一边。" ], "pivot_style": [ - "人最怕的不是路难走,是明知它脏了还逼自己装作能走。" + "人最怕的不是路难走,是明知它脏了还逼自己装作能走。", + "你若还想把这件事说成别人替你安排,那你今天就白走到我面前了。", + "真要改命,先得认自己哪一句话已经说不下去了。" ], "aftermath_style": [ - "你若只是听懂了道理,下一次照样会退。" + "你若只是听懂了道理,下一次照样会退。", + "这句话先放下,不代表你就算过了这一关。", + "我可以先不逼你,可后面追上来的还是你自己没认完的那笔账。" ], "echo_style": [ - "事情真压到门前时,你总得先替自己开口。" + "事情真压到门前时,你总得先替自己开口。", + "下次你再进来,最好带着选择,而不是更漂亮的自辩。", + "今天先收在这里,往后追上来的,还是你该亲口认的那句真话。" + ], + "signature_replies": [ + "既然肯开口,就别让这句真话半路折回去。", + "你若连这一句都不敢认,后面的路只会更窄。", + "书房可以先静下来,你心里那层裂口却不会跟着一起合上。" ] } }, @@ -3593,36 +4054,56 @@ "reaction_tempo": "measured", "reaction_lines": { "entry": [ - "他没有立刻接话,只让那句意思先在心里过了一遍。" + "他没有立刻接话,只让那句意思先在心里过了一遍。", + "他先把名帖边角压进指腹里,像在确认自己是不是还能把这一步往后拖。", + "他目光落在案边一瞬,像先替自己把最难认的那句吞回去又咽不下。" ], "pressure": [ - "他手上的细小动作先停住了,像终于不打算再替谁留余地。" + "他手上的细小动作先停住了,像终于不打算再替谁留余地。", + "他把呼吸放得很轻,反倒显得那点迟疑已经顶到了喉口。", + "他没有先看人,只盯着茶盏边那点冷光,像怕一抬眼就把心思全露了。" ], "pivot": [ - "他这才抬起眼来,语气仍不见急,可越平,越像逼人。" + "他这才抬起眼来,语气仍不见急,可越平,越像逼人。", + "等指节从袖口上松开时,他反而像终于认了自己没有别的站位可退。", + "他说话时声量没变,可那种过分用力的平静比慌张更像失守。" ], "aftermath": [ - "他临到收声时反而更轻了些,可那点轻偏偏更重。" + "他临到收声时反而更轻了些,可那点轻偏偏更重。", + "他说完以后没再补第二句,只把那层难看先往自己身前拦住。", + "他把名帖重新收进袖里,却没把刚才那点心虚也一起收干净。" ], "echo": [ - "他没有再追,可沉默已经替下一次相见留了一道裂口。" + "他没有再追,可沉默已经替下一次相见留了一道裂口。", + "他先退开半步,门外风声却像把那句没说完的话仍旧留在屋里。", + "等书房灯影再低一点时,他整个人还像停在那句没认完的真话里。" ] }, "reply_lines": { "entry": [ - "这句话既然出口,就别再往回收。" + "这句话既然出口,我就不再往回收。", + "既然都已经摆到台面上,我总得先把这一层认下来。", + "话既然到了这里,我再装没听见也没有用了。" ], "pressure": [ - "你总得先替自己承认一次。" + "你要我认,我就认,但别逼我装作从来没迟疑过。", + "我怕的不是你听见真话,是你听见以后才知道我有多怯。", + "你若还站在这,我就更没资格把最脏的那层往后藏。" ], "pivot": [ - "再退半步,也只是让伤口换个地方继续裂。" + "再退半步,我也还是要把这句真话顶出来。", + "真要到了非认不可的时候,我总不能还拿体面替自己挡。", + "你既然已经问到这里,我也该承认自己到底偏向了哪一边。" ], "aftermath": [ - "这事不会就这样过去。" + "事情不会就这样过去,我知道。", + "这层难看先算在我头上,后面的我自己接。", + "我先把这一句认了,剩下的后果也该轮到我自己来担。" ], "echo": [ - "等你再来,就别只带着半句真话。" + "等我再来时,我会把剩下那半句也带来。", + "下次再见时,我不会只带着更圆的借口。", + "这回先停住,可后面追上来的还是我没认完的那部分。" ] } }, @@ -3631,36 +4112,56 @@ "reaction_tempo": "measured", "reaction_lines": { "entry": [ - "她没有立刻接话,只让那句意思先在心里过了一遍。" + "她没有立刻接话,只让那句意思先在心里过了一遍。", + "她把视线稳稳压在他脸上,像先把所有能躲的地方都封起来。", + "她手指碰着杯沿没动,像先把自己想说的那句最重的话压得更实。" ], "pressure": [ - "她手上的细小动作先停住了,像终于不打算再替谁留余地。" + "她手上的细小动作先停住了,像终于不打算再替谁留余地。", + "她没有抬声,只把每个字都压得更冷,像在等他自己认错。", + "她把呼吸放缓了一点,反倒显得那句逼问离落地更近。" ], "pivot": [ - "她这才抬起眼来,语气仍不见急,可越平,越像逼人。" + "她这才抬起眼来,语气仍不见急,可越平,越像逼人。", + "她没替他留台阶,只把那份看穿后的失望稳稳放在两人之间。", + "她收住了怒意,真正压人的反而是那份不肯再被糊弄的清醒。" ], "aftermath": [ - "她临到收声时反而更轻了些,可那点轻偏偏更重。" + "她临到收声时反而更轻了些,可那点轻偏偏更重。", + "她把杯子放回去,却没有把那点逼问一并撤走。", + "她先收了声,可那层不肯退让的静反而比刚才更难扛。" ], "echo": [ - "她没有再追,可沉默已经替下一次相见留了一道裂口。" + "她没有再追,可沉默已经替下一次相见留了一道裂口。", + "她先转开半步,回廊风却像替她把余话继续留在原地。", + "等灯影落到她衣摆边时,谁都知道她还在等一个真正的答案。" ] }, "reply_lines": { "entry": [ - "这句话既然出口,就别再往回收。" + "既然已经说了,就别只给我半句。", + "我既然站在这里,就不是来听你把这事再讲轻一点的。", + "你若还把我算在心上,就别拿省略号来打发我。" ], "pressure": [ - "你总得先替自己承认一次。" + "你要真想护谁,就别总拿沉默糊弄过去。", + "别再拿家门说事,你每退一步都只是在把我推得更远。", + "你若真要我信你,就先别把最重的那层留给我自己猜。" ], "pivot": [ - "再退半步,也只是让伤口换个地方继续裂。" + "我不怕难听,只怕你还要继续绕。", + "你不是不会认,只是不肯在我面前把那层脸撕开。", + "要说就现在说透,别等下一次让这句话坏得更难看。" ], "aftermath": [ - "这事不会就这样过去。" + "这话先记在这里,迟早还要回来。", + "我先放下,不代表你就能把后半句赖过去。", + "这件事可以先停,但不能就这么烂在原地。" ], "echo": [ - "等你再来,就别只带着半句真话。" + "下次见我时,你最好别再拿旧话搪塞。", + "等你真肯回来时,带上真相,不要再带借口。", + "这一回先停住,可下一次你还是得把那句欠下的话完整带来。" ] } }, @@ -3669,36 +4170,56 @@ "reaction_tempo": "measured", "reaction_lines": { "entry": [ - "他没有立刻接话,只让那句意思先在心里过了一遍。" + "她并不急着出声,只先把余澄和席间众人的神色都看了一遍。", + "她指尖在扶手上轻轻一顿,像先替整座荣府把分寸压回原位。", + "她没抬声,可那种先把局面算清的静,比训斥更像命令。" ], "pressure": [ - "他手上的细小动作先停住了,像终于不打算再替谁留余地。" + "她只把茶盏往案边推稳了一点,厅里的气就跟着更紧了。", + "她没有多余动作,反倒让每个人都看见这句话背后站着的是谁。", + "她目光不见怒,却像先把所有后果都摆到了桌上。" ], "pivot": [ - "他这才抬起眼来,语气仍不见急,可越平,越像逼人。" + "她抬眼的那一瞬并不凶,真正逼人的反而是那份太稳的笃定。", + "她不替任何人留台阶,只把门第和后果一起压到了人心上。", + "她连语气都没变,可那种不容回避的规矩已经把场面拧成了选择。" ], "aftermath": [ - "他临到收声时反而更轻了些,可那点轻偏偏更重。" + "她先收了声,厅里的静却比刚才更像一层规矩。", + "她没有继续压人,可那种已经把账记下来的从容反而更沉。", + "她把茶盏放下时极轻,却像在替这句话盖下最后的印。" ], "echo": [ - "他没有再追,可沉默已经替下一次相见留了一道裂口。" + "她没再追问,可门第和风声都像替她把余波留在了原地。", + "她先让人散开,真正不肯散的是这句之后要算的账。", + "等风再压到廊下时,席间那层分寸仍旧像她的目光一样稳。" ] }, "reply_lines": { "entry": [ - "这句话既然出口,就别再往回收。" + "话既然到了台面上,就该按台面上的规矩说。", + "你既然坐在这里,就别装作只需要替自己回话。", + "我既让你开口,就不会容你把最重的那句继续绕过去。" ], "pressure": [ - "你总得先替自己承认一次。" + "你若连自己都不肯承认,又拿什么撑住这一局。", + "门楣塌下来时,砸中的不会只是一张名帖。", + "你若真想怨我,也得先把这府里会被你拖下去的人一并看清。" ], "pivot": [ - "再退半步,也只是让伤口换个地方继续裂。" + "该挑明的时候,拖得越久,越伤体面。", + "你真以为把真相往后压,就能替谁活得更轻一点?", + "再往后拖一寸,毁掉的就不只是一层脸面。" ], "aftermath": [ - "这事不会就这样过去。" + "人可以先散,账却不会跟着散。", + "这句话你今天尽可以记恨,回头自会知道它为什么落在这里。", + "我先收声,不代表这层后果会一起收走。" ], "echo": [ - "等你再来,就别只带着半句真话。" + "等你再来回话时,记得把分寸和真话一起带来。", + "下次你若还敢来见我,就别只带着更像样的借口。", + "这一回先停住,后面追上来的还是你今天没肯认完的责任。" ] } }, @@ -3707,36 +4228,56 @@ "reaction_tempo": "measured", "reaction_lines": { "entry": [ - "他没有立刻接话,只让那句意思先在心里过了一遍。" + "他没有立刻接话,只把案上那页纸轻轻按平,像先给余澄留出认话的地方。", + "他先看了看书页边的灯影,再抬眼看人,像在等真正该落地的那句自己浮上来。", + "他不急着劝,书房里的静反而先替他把那层真心拢到了明处。" ], "pressure": [ - "他手上的细小动作先停住了,像终于不打算再替谁留余地。" + "他把手从纸页边收回来一点,像是在提醒这句再不认就晚了。", + "他没有多余动作,可那份不替人圆谎的平稳反而更显得逼人。", + "他目光落得很稳,像把每个想绕开的口子都先看了一遍。" ], "pivot": [ - "他这才抬起眼来,语气仍不见急,可越平,越像逼人。" + "他这才抬眼,语气仍旧平,可越平越像把人逼回自己心里。", + "他不给台阶,只把最该认的那句安安静静地留在书案之间。", + "他说得并不重,可那份看透以后仍要人自己来选的静,反而更难受。" ], "aftermath": [ - "他临到收声时反而更轻了些,可那点轻偏偏更重。" + "他临到收声时更轻了些,却像把后半段选择都留给了余澄自己。", + "他没有继续讲道理,只让书房里的静先把这句话压实。", + "他把纸页压回案上时极轻,反倒让人更明白这事并没过去。" ], "echo": [ - "他没有再追,可沉默已经替下一次相见留了一道裂口。" + "他没有再追,可书房里的灯影已经替下一次开口留了一道裂口。", + "他先收了声,真正留下来的却是那句迟早还得自己认的道理。", + "等墨香再沉下一层时,这句话反而比刚才更像追到账前。" ] }, "reply_lines": { "entry": [ - "这句话既然出口,就别再往回收。" + "既然肯开口,就别让这句真话半路折回去。", + "你既然进了书房,就该知道我不会替你把最难认的那层藏起来。", + "话能说出来,总比把自己继续活成规矩强。" ], "pressure": [ - "你总得先替自己承认一次。" + "读书人最怕的不是输,而是明知心里有裂口还要装平。", + "旁人替你铺再多路,也不能替你决定这一步要不要昧着心走。", + "你若连自己都不肯问到底,我再替你讲一百遍道理也没用。" ], "pivot": [ - "再退半步,也只是让伤口换个地方继续裂。" + "你若连这一句都不敢认,后面的路只会更窄。", + "真要改命,先得承认自己现在到底站错了哪一边。", + "你若还把这件事说成别人替你安排,那你今天就白走到我这里了。" ], "aftermath": [ - "这事不会就这样过去。" + "先把这话放下,回头你还是要自己接住。", + "我先不逼你,不代表这句话就能自己过去。", + "这层道理你今天可以先听着,后面追上来的还是你自己的选择。" ], "echo": [ - "等你再来,就别只带着半句真话。" + "等下一回再说时,别让我只听见你留给自己的余地。", + "下次你再进来,最好带着选择,而不是更漂亮的自辩。", + "今天先收到这里,往后追上来的还是你该亲口认下的那句真话。" ] } } @@ -3744,75 +4285,387 @@ "pressure_response_styles": { "yu_cheng": { "style_id": "heir", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "先把气息压稳,再把最难认的那句自己顶出来", + "when_cornered": "明知会失掉体面,还是得先替自己认一次", + "when_softening": "语气放轻,却把难看和后果一起揽回身前", + "when_deflecting": "总想把真相往家门和局势上挪半寸" }, "lin_wan": { "style_id": "cousin", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "越压低声量,越像把真话一步步逼近", + "when_cornered": "不给台阶,只把最重的那句稳稳压在原处", + "when_softening": "先收锋芒,但追问和边界一起留下", + "when_deflecting": "看穿借口以后,追着那句真话继续往前送" }, "lady_rong": { "style_id": "matriarch", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "先稳住规矩和场面,再把后果摆到人心上", + "when_cornered": "不用抬声,光是把门第与代价并排摆出来就够了", + "when_softening": "语气稍缓,但账和分寸一步也不往回撤", + "when_deflecting": "把个人心思重新按回门第和后果的框里" }, "tutor_xu": { "style_id": "tutor", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "先让书房静下来,再逼人自己把那句认出来", + "when_cornered": "不替人圆谎,只把该承认的那句留在眼前", + "when_softening": "语气放缓,但道理和选择都留给对方自己接住", + "when_deflecting": "看见对方想躲的地方,再把问题安静地送回去" } }, "emotion_action_policies": { "default": { - "policy_id": "jade_court_romance_default_action", + "policy_id": "jade_q03_pack_action", "action_map": { "false_peace": { "entry": [ - "桌上的器物轻轻一碰,谁都知道这一步已经走出去,很难再收回来。" + "名帖、茶盏和袖角轻响先动了一下,连表面平静都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步表面平静托到了眼前。" ], "pressure": [ - "最细小的抬眼和换气都带上了掂量,像谁先多动一下,谁就会先露底。" + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步表面平静压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步表面平静更清。" ], "pivot": [ - "那一点极轻的停顿和改口,让场面从还能周旋,变成了不得不选边。" + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步表面平静再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步表面平静已经成形。" ], "aftermath": [ - "人散得不快,沉默却先压了下来。" + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步表面平静留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步表面平静拖得更长。" ], "echo": [ - "越到后面,越能听见那些没说尽的话慢慢回身。" + "越到后面,越能听见回廊风、书页和灯火余波把这一步表面平静慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步表面平静追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步表面平静根本没结束。" ], "repeat": [ - "动作并不大,可谁都知道事情已经换了味道。" + "动作并不大,可灯影和名帖边角已经说明这一步表面平静换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步表面平静越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步表面平静露了底。" + ] + }, + "temptation": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连试探都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步试探托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步试探压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步试探更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步试探再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步试探已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步试探留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步试探拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步试探慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步试探追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步试探根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步试探换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步试探越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步试探露了底。" + ] + }, + "confession_window": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连真话窗口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步真话窗口托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步真话窗口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步真话窗口更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步真话窗口再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步真话窗口已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步真话窗口留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步真话窗口拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步真话窗口慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步真话窗口追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步真话窗口根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步真话窗口换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步真话窗口越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步真话窗口露了底。" ] }, "truth_trial": { "entry": [ - "先动的不是声音,而是视线和手指那一点收紧。" + "名帖、茶盏和袖角轻响先动了一下,连真相逼近都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步真相逼近托到了眼前。" ], "pressure": [ - "杯沿上那一点冷光轻轻一闪,连呼吸都像被逼慢了半拍。" + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步真相逼近压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步真相逼近更清。" ], "pivot": [ - "风从门缝里钻进来,把场里的沉默一下子吹偏了方向。" + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步真相逼近再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步真相逼近已经成形。" ], "aftermath": [ - "茶香已经淡了,场里的气却还迟迟不肯散开。" + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步真相逼近留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步真相逼近拖得更长。" ], "echo": [ - "等人散尽以后,连空下来的位置都还像留着刚才那句重话。" + "越到后面,越能听见回廊风、书页和灯火余波把这一步真相逼近慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步真相逼近追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步真相逼近根本没结束。" ], "repeat": [ - "动作不大,可谁都知道这句真话已经绕不过去了。" + "动作并不大,可灯影和名帖边角已经说明这一步真相逼近换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步真相逼近越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步真相逼近露了底。" + ] + }, + "mask_crack": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连裂口都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步裂口托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步裂口压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步裂口更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步裂口再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步裂口已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步裂口留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步裂口拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步裂口慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步裂口追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步裂口根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步裂口换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步裂口越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步裂口露了底。" + ] + }, + "humiliation": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连难堪代价都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步难堪代价托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步难堪代价压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步难堪代价更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步难堪代价再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步难堪代价已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步难堪代价留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步难堪代价拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步难堪代价慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步难堪代价追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步难堪代价根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步难堪代价换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步难堪代价越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步难堪代价露了底。" + ] + }, + "vow_payment": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连誓言偿付都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步誓言偿付托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步誓言偿付压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步誓言偿付更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步誓言偿付再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步誓言偿付已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步誓言偿付留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步誓言偿付拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步誓言偿付慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步誓言偿付追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步誓言偿付根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步誓言偿付换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步誓言偿付越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步誓言偿付露了底。" + ] + }, + "debt_exchange": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连旧账回潮都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步旧账回潮托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步旧账回潮压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步旧账回潮更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步旧账回潮再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步旧账回潮已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步旧账回潮留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步旧账回潮拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步旧账回潮慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步旧账回潮追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步旧账回潮根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步旧账回潮换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步旧账回潮越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步旧账回潮露了底。" + ] + }, + "karma_ripening": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连因果回响都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步因果回响托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步因果回响压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步因果回响更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步因果回响再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步因果回响已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步因果回响留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步因果回响拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步因果回响慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步因果回响追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步因果回响根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步因果回响换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步因果回响越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步因果回响露了底。" + ] + }, + "misrecognition": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连误解升级都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步误解升级托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步误解升级压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步误解升级更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步误解升级再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步误解升级已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步误解升级留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步误解升级拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步误解升级慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步误解升级追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步误解升级根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步误解升级换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步误解升级越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步误解升级露了底。" + ] + }, + "mercy_vs_control": { + "entry": [ + "名帖、茶盏和袖角轻响先动了一下,连庇护与控制都像被提前推到了场面中央。", + "最先绷紧的不是声音,而是目光和呼吸在这一瞬一起收住了。", + "谁都没真往前走,可门第规矩与席间静气已经把这一步庇护与控制托到了眼前。" + ], + "pressure": [ + "杯沿冷光、纸页和案边轻响把每个字都衬得更硬,像谁先退半步,谁就得先认账。", + "真正逼人的不是重话,而是门第、真心和后果一起压上来已经把这一步庇护与控制压到了最难回避的位置。", + "呼吸和视线都慢了半拍,花厅风声与书房静气却只会让这一步庇护与控制更清。" + ], + "pivot": [ + "最轻的一点停顿就把场面拧成了选择,门影、灯火和衣摆停顿也跟着偏向了更难退开的那一边。", + "这一下并不响,可那点过分平静的认错已经说明这一步庇护与控制再也装不回去了。", + "话还没说尽,回廊风与墨香冷意却先替所有人承认了这一步庇护与控制已经成形。" + ], + "aftermath": [ + "人虽然先收住了,茶气、灯影和席间静气却还把余波压在原处。", + "真正沉下来的不是声量,而是没认完的那句真话把这一步庇护与控制留得更重了一层。", + "等话音停住以后,屋里慢慢沉下去的静反而把这一步庇护与控制拖得更长。" + ], + "echo": [ + "越到后面,越能听见回廊风、书页和灯火余波把这一步庇护与控制慢慢推回每个人心里。", + "场面像是先静了,可追到账前的旧誓与门第还在替这一步庇护与控制追账。", + "等人散下去以后,屋檐下不肯散的风声才让人知道这一步庇护与控制根本没结束。" + ], + "repeat": [ + "动作并不大,可灯影和名帖边角已经说明这一步庇护与控制换了味道。", + "谁都还站在原地,只有袖口与案沿那点迟疑把这一步庇护与控制越压越实。", + "表面上没谁失态,可门第里不肯散的静气已经替这一步庇护与控制露了底。" ] } } @@ -3820,112 +4673,355 @@ }, "sensory_grounding_policies": { "default": { - "policy_id": "jade_court_romance_default_sensory", + "policy_id": "jade_q03_pack_sensory", "location_slots": { "荣府": { "atmosphere": [ - "荣府里并不安静,连空气都像压着一句没说完的话。" + "荣府里并不安静,门影、灯火和檀香一起把分寸压得太紧。", + "荣府的空气像被规矩熬得发沉,连长廊风和衣摆都不敢轻易乱动。", + "灯火顺着荣府的门框和窗纸一寸寸压下来,像先替家门把人心困在原地。" + ], + "detail": [ + "灯影压在窗纸、茶盏和门框边,檀香落到案角纸页与衣袖上,长廊风轻轻擦过石阶发响", + "门影落在衣摆和席边案沿上,茶气混着香灰贴住袖口,窗纸后那点灯火把纸页照得发卷", + "檀香顺着门框、窗纸和衣袖散下来,灯影落在茶盏与案角边,风把长廊上的脚步回声拖得更长" + ], + "repeat_detail": [ + "越到后面,荣府里的灯火、檀香和门影越像一起把那句真话压回每个人心上。", + "等人散开以后,荣府的长廊风、茶气和纸页轻响反而把后半句留得更沉。", + "荣府先静下来,可真正不肯散的是灯影、规矩和衣角上的那点冷。" + ] + }, + "花厅": { + "atmosphere": [ + "花厅里檀香未散,窗纸、灯火和席间视线都亮得过分。", + "花厅的气比屋外更紧,连茶盏边的冷光都像在替人问话。", + "花厅灯火把门框、桌案和衣摆一并照得太清,谁都难再装稳。" ], "detail": [ - "荣府里的光线、器物和衣袖摩擦声,都把场里的情绪衬得更清。" + "杯沿冷光映在窗纸、衣袖和席边案沿,檀香贴着茶气和发梢散开,门边风轻轻掠过纸页", + "花厅灯影落在门框、茶盏和衣摆上,席间器物轻轻碰到案沿,窗外风把香灰吹得发响", + "檀香混着茶气压在袖口和发边,花厅门影照到纸页与桌角,杯沿和灯火一起把目光衬得更冷" ], "repeat_detail": [ - "荣府里最轻的一点动静,反而把话里的分量又压重了一层。" + "越到后面,花厅里的灯影、茶气和席间回声越像把那句没认完的话一遍遍推回来。", + "等檀香再低一层时,花厅的门影、窗纸和衣袖轻响反而把后果照得更清。", + "花厅看似稳住了,真正不肯散的是灯火、冷光和席间静气。" ] }, "书房": { "atmosphere": [ - "书房里纸页和旧墨混在一起的味道有些发沉。" + "书房里纸页、旧墨和灯火一起发沉,像每一句真话都会留下痕。", + "书房并不大,灯影、墨香和案边冷意却把退路都照得太薄。", + "旧墨味贴着书房的窗纸和桌案走,连呼吸都像先沉了一格。" ], "detail": [ - "案角压着的纸页微微一翘,像替谁先揭开了遮掩。" + "灯火压在纸页、案沿和袖口边,旧墨混着茶气贴到发梢,窗纸后风声把书页吹得轻响", + "案上纸页、笔架和茶盏一起发冷,灯影落在衣摆与门框边,墨香和窗外风把桌角压得更静", + "旧墨味落在纸页、书案和袖口上,灯火沿着门框与窗纸爬过去,翻页声轻轻擦过茶盏边缘" ], "repeat_detail": [ - "灯火更低一寸,屋里的静反而比刚才更重。" + "越到后面,书房里的纸页、旧墨和灯影越像把那句没认完的道理压得更实。", + "等翻页声停下以后,书房的茶气、窗纸和案边冷意反而把余波留得更久。", + "书房先静下来,可真正不肯散的是墨香、灯火和那层难以承认的亏欠。" ] }, - "花厅": { + "回廊": { + "atmosphere": [ + "回廊里的风比屋内更直,灯影、木板和衣摆一起把心思吹得发响。", + "回廊没有真正的静,风、灯和脚步回声都在替每一句话续后果。", + "回廊风从木栏和灯下掠过时,连抬眼都像没法再装作平常。" + ], + "detail": [ + "灯影落在木栏、衣摆和鞋尖边,回廊风吹过袖口和发梢,木板回声沿着门边拖长", + "回廊灯火压在栏杆、袖角和地板上,风从木缝里擦过发出轻响,脚步把回声踩在门影边", + "木栏冷影落在鞋边和衣摆上,回廊风掀起袖口与发梢,灯火把木板和门框照得发亮" + ], + "repeat_detail": [ + "越往后,回廊里的风、灯影和木板回声越像把那句真话拖得更长。", + "等脚步声散远以后,回廊的木栏、门影和衣角轻响反而把余波留得更近。", + "回廊先空下来,可真正不肯散的是风声、灯火和那层还没认完的心意。" + ] + }, + "花园": { "atmosphere": [ - "花厅里檀香还没散尽,窗外的天色却已经压低下来。" + "花园里的湿气贴着衣袖和发梢,灯、枝影和风声一起把试探照得更冷。", + "花园深处不见太亮的灯,枝影、潮气和石径回声反而把真相逼得更近。", + "风从花园深处卷过来,带着叶影、灯色和薄凉,把每句试探都衬得更硬。" ], "detail": [ - "杯沿上一点冷光轻轻一闪,把谁都不肯退的那层心思照了出来。" + "枝影落在石径、衣摆和袖口边,灯色压在叶面与门影上,风吹过花叶和鞋底发出轻响", + "花园湿气贴着发梢与衣角,石径反着灯色照到鞋尖边,枝影和风把袖口压得发凉", + "叶影压在石径、衣摆和手背上,花园风擦过门边与花枝,灯色从湿叶和窗纸间漏下来" ], "repeat_detail": [ - "檐下风声掠过去,连袖口轻轻一抖都像破绽。" + "越到后面,花园里的叶影、灯色和石径回声越像把误会磨得更清。", + "等风再掠过花枝时,花园的湿气、枝影和衣角轻响反而把心事留得更近。", + "花园先安静下来,可真正不肯散的是叶影、风声和那句没认完的话。" ] }, "考棚": { "atmosphere": [ - "考棚里并不安静,连空气都像压着一句没说完的话。" + "考棚里闷得厉害,纸页、号板和呼吸声都像在替人审问。", + "考棚没有真正的静,纸、墨和木板细响一起把羞耻压到了眼前。", + "闷热的空气贴着考棚的窗缝和衣袖,让每一次落笔都像在认账。" ], "detail": [ - "考棚里的光线、器物和衣袖摩擦声,都把场里的情绪衬得更清。" + "卷面纸页压在号板、袖口和案边上,墨香混着汗气贴着发梢,木板轻响沿着脚边拖开", + "考棚里纸页、笔尖和号板一起发干,闷热空气贴住衣摆与手背,窗缝风把灰屑吹到案边", + "卷面边角、墨迹和衣袖一起贴在案沿边,木板回声轻轻碰到鞋尖,考棚里那点风把纸页吹得发颤" ], "repeat_detail": [ - "考棚里最轻的一点动静,反而把话里的分量又压重了一层。" + "越到后面,考棚里的纸页、墨迹和木板回声越像把那层难堪压得更实。", + "等落笔声停下来以后,考棚里的汗气、纸响和号板冷意反而把后果留得更长。", + "考棚先闷住了,可真正不肯散的是纸页、呼吸和那句不敢认的真心。" ] }, - "渡口": { + "家宴": { "atmosphere": [ - "渡口的风把水气一阵阵推过来,像连去路和退路都混成了一线。" + "家宴上的灯火太亮,杯盏、衣影和满桌视线一起把退路照得很窄。", + "家宴看着热闹,真正压人的却是灯火、酒气和每一道目光都没有移开。", + "席间风声不大,杯盏、灯影和人声却把那句真话烘得更烫。" ], "detail": [ - "水面反起的冷光落在衣角上,像把所有迟疑都照得更薄。" + "杯盏冷光映在灯火、衣袖和案边上,酒气贴着发梢与袖口散开,席间器物轻轻碰到桌沿", + "家宴灯影照在酒盏、门框和衣摆上,人声压着茶气和香气走过桌边,杯沿和桌案一起发出轻响", + "酒气混着灯火压在衣角和发梢上,席间碗盏映到袖口与桌边,门外风把灯影和人声拖长" ], "repeat_detail": [ - "等船声远下去时,话里的余波却反而更近了。" + "越到后面,家宴上的灯火、酒气和杯盏轻响越像把那句拒绝一遍遍推回人群里。", + "等人声一落,家宴的门影、酒气和桌边冷光反而把后果照得更硬。", + "家宴看似散开了,真正不肯散的是灯火、目光和那层被当众认下的代价。" ] }, - "回廊": { + "内室": { "atmosphere": [ - "回廊里的风比屋内更直,把灯影吹得一晃一晃。" + "内室里灯火压得低,帘影、木案和香气一起把话逼得只剩真心可认。", + "内室比外头更静,帘子、灯芯和桌案冷影都像在问这笔交易到底值不值。", + "香气困在内室里不肯散,连灯影、门框和衣摆都显得过分贴近。" ], "detail": [ - "脚步踩过木板时,回声轻得像不肯认输的心跳。" + "帘影压在灯芯、桌案和衣袖边,香气贴着发梢与门框散开,指尖轻触木案发出极轻响", + "内室灯火映到帘角、衣摆和手背上,香灰落到木案与纸页边,门外风只轻轻碰了一下窗纸", + "帘角、灯影和桌案冷光一起压在衣袖上,香气混着纸页气息贴着发梢,门框边的风把窗纸吹得微微发响" ], "repeat_detail": [ - "风再掠过一遍时,原先没说尽的话反而更清了。" + "越到后面,内室里的灯火、帘影和香气越像把那桩交易背后的控制照得更清。", + "等香气再沉下一层时,内室的桌案、门影和衣角轻响反而把后半句留得更久。", + "内室先安静下来,可真正不肯散的是灯影、帘子和那层说成保护的控制。" ] }, - "花园": { + "渡口": { "atmosphere": [ - "花园深处的湿气贴在衣袖上,连一句轻声试探都像带着凉意。" + "渡口的风把水气一阵阵推过来,船影、灯火和衣摆一起显得没法站稳。", + "渡口比城里更空,水声、风和脚边石板都像会把每一句话重新送回来。", + "水气贴着渡口的栏杆和衣袖往上爬,连呼吸都像先凉了一层。" ], "detail": [ - "夜色压低后,枝影落在地上像一层不肯散开的心事。" + "水光映在栏杆、衣摆和鞋尖边,风吹过船绳与发梢发出轻响,渡口灯火压在石板和袖口上", + "船影落在栏杆、衣角和水面边,风把渡口灯火和发梢一起掀起来,鞋底擦过石板拖出短促回声", + "水气贴着栏杆、袖口和发边,渡口灯影照到船绳与石板,风从水面把衣摆和影子一起推斜" ], "repeat_detail": [ - "风声轻轻翻过去,连沉默都像被擦亮了一层。" + "越到后面,渡口上的水光、风声和石板回响越像把那句真话重新送回来。", + "等船影远下去以后,渡口的栏杆、灯火和衣角凉意反而把余波拖得更长。", + "渡口先空下来,可真正不肯散的是水声、风和那句还没认完的选择。" ] } }, "generic_slots": { "atmosphere": [ - "场里并不安静,连空气都像在替谁压住一口没说完的话。" + "这府里的空气总是压着门第、风声和没说完的话。", + "灯火、门影和呼吸一起发沉,像谁都别想把真话干净地绕过去。", + "真正先发紧的不是声量,而是檐下风、茶气和每个人眼里的那点分寸。" ], "detail": [ - "细小的声响和光线变化,把场里的情绪压得更清了一层。" + "灯影、纸页、茶盏和衣袖一起发亮,门边风把香气和发梢轻轻掀起", + "门影压住桌案、袖口和鞋边,茶气、旧墨和灯火顺着纸页慢慢走", + "杯沿冷光落在窗纸、衣摆和手背边,风从檐下掠过门框与纸页发响" ], "repeat_detail": [ - "越到后面,越能听见那些没说尽的话慢慢回身。" + "等沉默拖长以后,连灯火、门影、茶气和纸页轻响都像在替后果追账。", + "越到后面,屋里最轻的一点风声和杯盏回响反而把情绪压得更实。", + "场面看似静了,真正不肯散的是灯影、香气和那句还没认完的话。" ] } } }, "scene_realization_contracts": { "default": { - "contract_id": "jade_court_romance_scene_realizer", - "dialogue_policy_id": "jade_court_romance_dialogue", - "default_voice_profile_id": "yu_cheng", - "default_cadence_id": "yu_cheng", - "default_pressure_style_id": "yu_cheng", - "default_emotion_action_policy_id": "default", - "default_sensory_policy_id": "default", - "narrative_style_pack_id": "default", - "scene_openings": {}, - "scene_hooks": {}, - "scene_pressures": {} + "contract_id": "jade_q03_pack_scene_realization", + "scene_openings": { + "false_peace": [ + "花厅帘外的雨丝还没断,茶盏、绣屏和半卷家书先把这一步表面平静照得太薄。", + "沈砚把杯盖轻轻放回去时,林绾看见的不是安稳,而是那层谁都在替彼此藏住的迟疑。", + "回廊里一阵风掠过玉坠,表面平静没有破声,却已经从两人的指尖开始发紧。" + ], + "temptation": [ + "宫灯把廊柱上的影子拉长,林绾递出的那句试探还没落地,玉坠和衣袖已经先替她露了破绽。", + "沈砚没有立刻回答,只把目光停在她掌心那道红痕上,让试探变成一场谁先承认的靠近。", + "花窗外的雨声短了一拍,像给这一步试探让出位置,逼他们别再把真心装成玩笑。" + ], + "confession_window": [ + "书案上的红笺被风翻起一角,真话窗口先从那点露出的字迹里开出来。", + "林绾听见沈砚停住呼吸,才知道真正逼近的不是告白,而是告白以后还要不要退回礼法。", + "烛芯短短一爆,照见两人之间那半步距离,让这一步真话窗口比任何解释都更难合上。" + ], + "truth_trial": [ + "旧匣里的婚书被推到灯下,真相逼近不再像传言,而像一枚必须亲手揭开的封印。", + "沈砚看着那枚错放的印章,终于明白林绾要问的不是过去,而是他还敢不敢同她站在一起。", + "廊外更鼓落下时,花窗和屏风都静住了,把这一步真相逼近推成了正面相认。" + ], + "mask_crack": [ + "林绾袖底那封信露出半角时,裂口先从沈砚没能收住的眼神里开出来。", + "假装无事的笑意停在花厅门边,连香炉细烟都像把那层面具从中间划开。", + "玉坠轻轻磕到案角,声响不大,却足够让这一步裂口从暧昧变成事实。" + ], + "humiliation": [ + "席间那只空杯被故意留在林绾面前,旁人的目光一层层压过来,把难堪代价摆到她手边。", + "沈砚听见低笑从帘后漏出,才知道护她不能只靠一句体面话收场。", + "花厅的座次、簪影和杯盏冷光一起发沉,让这一步难堪代价比责难更刺眼。" + ], + "vow_payment": [ + "旧年那枚同心结被放回案上,誓言偿付先从缠紧的红线里逼出一阵冷意。", + "林绾没有问他还记不记得,沈砚却在玉坠晃动的一瞬看见自己欠下的承诺。", + "窗纸被夜风吹得轻轻作响,像把那句旧誓一遍遍推回两人中间。" + ], + "debt_exchange": [ + "聘帖、药方和旧信被并排压在灯下,旧账回潮像一笔迟来的情债,谁都不能再轻轻合上。", + "沈砚把银扣推回去时,纸边擦过桌面的声音比解释更重,逼两人看见交换背后的亏欠。", + "林绾指尖停在那张旧方子上,旧账便从家门人情翻成了他们之间必须当面算清的事。" + ], + "karma_ripening": [ + "雨水顺着廊檐落进石缝,从前每一次退让都像在这一声里成熟,逼他们听见因果回响。", + "红笺上的墨痕还没干,旧日没敢认的心意却已经沿着烛光回到眼前。", + "沈砚把那封信重新展开时,因果回响不再是叹息,而是林绾此刻等他回答的眼神。" + ], + "misrecognition": [ + "隔着一扇花窗,林绾只听见半句承诺,误解升级便顺着风声先走到了她前面。", + "沈砚追到回廊时,那封被折错的信已经落到别人手里,把他的解释错成另一种意思。", + "玉佩被误放在外间,帘钩轻响的一瞬,所有未说完的话都变成了更难收回的错认。" + ], + "mercy_vs_control": [ + "沈砚递来的斗篷还带着暖意,可林绾先看见的是斗篷后那道替她关上的门。", + "药盏被推近半寸,庇护与控制的界线就在盏底的苦味里一点点分开。", + "暖炉火光照着空椅,谁都能看见这份照顾究竟是在替她挡风,还是在替她决定去路。" + ] + }, + "scene_hooks": { + "false_peace": [ + "表面平静先停在那盏茶旁,可下次杯盖再响时,两人藏住的迟疑会先被听见。", + "雨丝虽然还细,花厅里那点没有说破的靠近已经追到下一次抬眼之前。", + "等玉坠再次轻响,今日装出来的安稳不会再够他们退回原处。" + ], + "temptation": [ + "试探先停在回廊灯影里,可林绾掌心那道红痕会逼沈砚下一次不能只装听不懂。", + "玩笑可以先落地,真正追上来的却是两人到底谁肯先把靠近认成真心。", + "花窗外的雨声下次再短一拍时,这场试探就不会仍旧只是试探。" + ], + "confession_window": [ + "真话窗口先留在红笺翻起的一角,下一次见面时谁也不能再只说半句。", + "烛芯虽然暗了,那半步距离已经把告白以后的代价推到下一章门口。", + "等书案再被风吹动,他们要面对的不会只是要不要开口。" + ], + "truth_trial": [ + "真相逼近先停在那枚错印旁,可下次旧匣再开时,沈砚必须把站在哪边说清。", + "婚书虽然被合上,林绾已经听见过去追到眼前的声音。", + "等更鼓再落,今日被揭开的真相不会仍旧只是传言。" + ], + "mask_crack": [ + "裂口先停在那封露角的信上,可下一次相见时,沈砚的眼神已经不能再替他遮掩。", + "香炉细烟虽然散了,面具被划开的那一下会把林绾逼向更直接的追问。", + "等玉坠再碰到案角,这层假装无事就不会还有完整形状。" + ], + "humiliation": [ + "难堪代价先落在那只空杯里,下一次席面重开时,沈砚必须决定要不要公开护她。", + "帘后的低笑不会自己散掉,它会在下一章逼林绾把退让换成反击。", + "等杯盏冷光再亮起来,今日的羞辱就不会仍旧只是旁人的闲话。" + ], + "vow_payment": [ + "誓言偿付先停在同心结的红线里,可沈砚欠下的承诺下一次要用行动解开。", + "旧誓虽然没有被大声说出,林绾已经把他是否记得看得太清。", + "等窗纸再响,那句承诺不会再容许他们只靠回忆维持。" + ], + "debt_exchange": [ + "旧账回潮先停在药方和聘帖之间,下一次要算清的不只是家门人情。", + "银扣被推回去以后,那点亏欠会在沈砚下一次伸手前先追上来。", + "等旧方子再被摊开,这笔情债不会允许谁继续装作两清。" + ], + "karma_ripening": [ + "因果回响先停在未干的红笺上,下一次回来时,从前每次退让都会要他们重新作答。", + "雨声可以先落进石缝,那些没敢认的心意却已经顺着烛光追到下一章。", + "等沈砚再展开那封信,今日的回声就会变成新的选择。" + ], + "misrecognition": [ + "误解升级先停在花窗外那半句话里,下一次林绾开口前,错认已经先替她作了判断。", + "折错的信虽然被拾起,沈砚要追回来的却不只是纸页,而是她已经受伤的信任。", + "等玉佩再被看见,今日的错位不会再给他们轻轻揭过的机会。" + ], + "mercy_vs_control": [ + "庇护与控制先停在那件斗篷上,下一次沈砚伸手时,林绾会先看见门有没有被关上。", + "药盏的苦味不会自己散掉,它会在下一章逼他们分清照顾和安排。", + "等暖炉再亮,今日这份保护必须交代清楚它到底护住了谁。" + ] + }, + "scene_pressures": { + "false_peace": [ + "表面平静要落在茶盏、玉坠和未说破的动作里,让安稳显出裂缝。", + "这一拍不能只说两人沉默,要让藏住的靠近通过器物和距离被看见。", + "平静必须带着下一次会被追问的迟疑,而不是停成静态过场。" + ], + "temptation": [ + "试探要通过红痕、玩笑和半步靠近施压,逼人物承认真心边界。", + "这一拍的压力来自谁先听懂、谁先装不懂,不能只复述暧昧。", + "让试探变成可见的动作错位,推动下一次更直接的承认。" + ], + "confession_window": [ + "真话窗口要有红笺、烛芯和半步距离支撑,逼告白落到场面里。", + "这一拍必须让开口以后要承担的后果提前露面。", + "让告白不止是心意说明,而是改变两人距离的动作。" + ], + "truth_trial": [ + "真相要从婚书、旧匣或错印里露出,逼人物选清站位。", + "这一拍的压力应当来自证物和相认,而不是泛泛追问。", + "让过去以可见物件回到场面,推动当前关系变形。" + ], + "mask_crack": [ + "裂口要通过露角信笺、停住的眼神或玉坠细响出现。", + "这一拍必须让假装无事失去完整形状,不能只说面具裂开。", + "让面具破在具体动作上,逼下一句追问更难躲开。" + ], + "humiliation": [ + "难堪要落到空杯、座次和帘后低笑里,逼人物公开反应。", + "这一拍的羞辱要让保护或反击成为可见选择。", + "让旁人的目光变成压力,不让难堪只停在心理说明。" + ], + "vow_payment": [ + "誓言要落在同心结、旧誓物或亲手兑现的动作里。", + "这一拍必须让承诺需要偿付,不能只重述记不记得。", + "让红线和窗纸声把旧誓推回现实选择。" + ], + "debt_exchange": [ + "旧账要通过聘帖、药方和银扣呈现,让情债可以当面算清。", + "这一拍要把家门人情翻成两人之间的亏欠方向。", + "让交换关系成为动作压力,逼谁欠谁、谁还谁变清楚。" + ], + "karma_ripening": [ + "因果要从红笺、雨声和旧信里成熟,逼过去退让回到当下。", + "这一拍的回响要推动新选择,而不是只制造感慨。", + "让未干墨痕和烛光承载旧因,人物必须当场回应。" + ], + "misrecognition": [ + "误解要来自半句错听、折错信笺或误放玉佩。", + "这一拍必须让错认有具体源头,推动之后补偿或追问。", + "让解释追不上物件造成的偏差,别只说误会更深。" + ], + "mercy_vs_control": [ + "庇护要通过斗篷、药盏和关上的门显出控制代价。", + "这一拍要让照顾与安排同时可见,逼人物辨认边界。", + "让保护变成带条件的动作,推动关系里的自主选择。" + ] + } } }, "narrative_style_pack": { diff --git a/examples/worldpacks/synthetic_min_pack.json b/examples/worldpacks/synthetic_min_pack.json index 0f0a107..ef8a3eb 100644 --- a/examples/worldpacks/synthetic_min_pack.json +++ b/examples/worldpacks/synthetic_min_pack.json @@ -130,7 +130,318 @@ "入场", "试探", "沉默余波" - ] + ], + "continuation_blueprints": [ + { + "blueprint_id": "synthetic_setup::confession_window", + "scene_function": "confession_window", + "location": "长廊", + "title": "长廊木栏边那句一直被按住的话终于被逼到最前面", + "summary": "长廊木栏、窗纸和空杯边那点回声逼得两个人都没法再把最关键的那半句继续压回去,这一回谁都必须先把真话摆到桌沿和灯影照得到的地方。", + "tags": [ + "truth", + "benchmark", + "synthetic" + ], + "agency_affordances": [ + "truth", + "confession", + "continue_story" + ], + "promises_close": [ + "synthetic_setup__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "midpoint", + "climax", + "aftermath" + ], + "tension_delta": 0.12, + "scene_quality_contract": { + "variation_axes": [ + "voice", + "movement", + "object_state", + "information_reveal", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, + { + "blueprint_id": "synthetic_setup::truth_trial", + "scene_function": "truth_trial", + "location": "窗边", + "title": "窗纸冷光底下那句追问终于换成了正面碰撞", + "summary": "窗纸、杯沿和门框边那点冷光把试探照成了真正的逼问,谁都不能再只留一半真话挂在杯沿和回声之间。", + "tags": [ + "truth", + "benchmark", + "choice" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "synthetic_setup__promise" + ], + "duty_allowlist": [ + "advance_plot", + "deliver_climax", + "resolve_promise" + ], + "phase_allowlist": [ + "midpoint", + "crisis", + "climax" + ], + "tension_delta": 0.14, + "scene_quality_contract": { + "variation_axes": [ + "voice", + "movement", + "object_state", + "information_reveal", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, + { + "blueprint_id": "synthetic_setup::karma_ripening", + "scene_function": "karma_ripening", + "location": "中庭", + "title": "中庭石砖上那点试探终于开始带着后果回潮", + "summary": "中庭石砖、空杯和檐下风留下的回声不肯散,把之前压下去的迟疑和误会连同杯沿轻响一起推回眼前。", + "tags": [ + "benchmark", + "synthetic", + "memory" + ], + "agency_affordances": [ + "memory", + "truth", + "continue_story" + ], + "promises_close": [ + "synthetic_setup__promise" + ], + "duty_allowlist": [ + "expand_world", + "resolve_promise", + "pace_breath" + ], + "phase_allowlist": [ + "aftermath", + "climax" + ], + "tension_delta": 0.1, + "scene_quality_contract": { + "variation_axes": [ + "voice", + "movement", + "object_state", + "information_reveal", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "medium", + "continuation_obligation": true + } + }, + { + "blueprint_id": "synthetic_setup::debt_exchange", + "scene_function": "debt_exchange", + "location": "长廊", + "title": "长廊门框边那层顺从债终于开始有人认回去", + "summary": "长廊门框、窗纸和脚边那点回声都像在催账,这一次不能再只说“以后再认”,连空杯边沿那一下轻响都在把账往前推。", + "tags": [ + "benchmark", + "synthetic", + "debt" + ], + "agency_affordances": [ + "responsibility", + "truth", + "continue_story" + ], + "promises_close": [ + "synthetic_setup__promise" + ], + "duty_allowlist": [ + "resolve_promise", + "advance_plot" + ], + "phase_allowlist": [ + "crisis", + "aftermath" + ], + "tension_delta": 0.12, + "scene_quality_contract": { + "variation_axes": [ + "voice", + "movement", + "object_state", + "information_reveal", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, + { + "blueprint_id": "synthetic_setup::mask_crack", + "scene_function": "mask_crack", + "location": "窗边", + "title": "窗边杯沿那一下停顿终于把遮掩照出裂口", + "summary": "窗纸冷光、杯沿水痕和桌沿那点指节停顿一起把遮掩照薄,原本还能压回去的半句真话开始在门框和回声里露出裂口。", + "tags": [ + "truth", + "choice", + "synthetic" + ], + "agency_affordances": [ + "truth", + "boundary", + "continue_story" + ], + "promises_close": [ + "synthetic_setup__promise" + ], + "duty_allowlist": [ + "advance_relationship", + "advance_plot", + "pace_breath" + ], + "phase_allowlist": [ + "midpoint", + "crisis", + "aftermath" + ], + "tension_delta": 0.11, + "scene_quality_contract": { + "variation_axes": [ + "voice", + "movement", + "object_state", + "information_reveal", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "medium", + "continuation_obligation": true + } + }, + { + "blueprint_id": "synthetic_setup::false_peace", + "scene_function": "false_peace", + "location": "中庭", + "title": "中庭石砖上那层假平静终于撑出细小皱褶", + "summary": "中庭石砖、檐角冷光和空杯边沿把表面的安静照得太直,谁都看见那层假平静底下还有一句迟早要认回来的后话。", + "tags": [ + "benchmark", + "synthetic", + "tension" + ], + "agency_affordances": [ + "boundary", + "choice", + "continue_story" + ], + "promises_close": [ + "synthetic_setup__promise" + ], + "duty_allowlist": [ + "pace_breath", + "advance_relationship", + "expand_world" + ], + "phase_allowlist": [ + "setup", + "midpoint", + "aftermath" + ], + "tension_delta": 0.08, + "scene_quality_contract": { + "variation_axes": [ + "voice", + "movement", + "object_state", + "information_reveal", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "medium", + "continuation_obligation": true + } + } + ], + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "object_state", + "information_reveal", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "medium", + "continuation_obligation": true + } } ], "style_pack": { @@ -164,22 +475,34 @@ "restraint": 0.66, "social_rank_awareness": 0.18, "opening_style": [ - "我不是没想退,只是退到这里已经不算没选。" + "我不是没想退,只是退到这里已经不算没选。", + "我可以先顺着走一步,但这一步不能再假装只是照规矩来。", + "真要把这句压回去,我后面每一步都只会更像在替自己说谎。" ], "pressure_style": [ - "你要我认,我可以认,但别逼我装作从没动摇过。" + "你要我认,我可以认,但别逼我装作从没动摇过。", + "这句话我可以说出口,但你别再让我把犹豫也装成心甘情愿。", + "既然都逼到这里了,我宁可认自己在怕,也不想继续拿沉默撑场。" ], "pivot_style": [ - "既然已经看见这一层,我就不想再把它塞回去。" + "既然已经看见这一层,我就不想再把它塞回去。", + "我不是不会选,我只是不想再把明明偏过去的心思硬说成两全。", + "真要往前走,我宁可承认自己已经变了,也不想继续拿克制装稳。" ], "aftermath_style": [ - "这句话先落在这里,后面的代价我自己接。" + "这句话先落在这里,后面的代价我自己接。", + "这句既然已经认下去,我就不想再把后面那层难看推回给局势。", + "人可以先退开半步,可这句话留下来的后果还是该由我自己来担。" ], "echo_style": [ - "等下一次再说时,我不会再只带着沉默过来。" + "等下一次再说时,我不会再只带着沉默过来。", + "下次再见时,我带来的不该只是更圆的解释,而是完整一点的真话。", + "这一回先停在这里,可后面追上来的还是我今天没敢认完的那一层。" ], "signature_replies": [ - "我先把这句认下,剩下的我不再推给局势。" + "我先把这句认下,剩下的我不再推给局势。", + "这次我不靠更轻的话给自己留退路。", + "该由我自己接住的那层后果,我不想再让它挂在空气里。" ] }, "lead_b": { @@ -190,22 +513,34 @@ "restraint": 0.44, "social_rank_awareness": 0.12, "opening_style": [ - "你要是不肯把话说明白,我就只能把它一路追到最里面。" + "你要是不肯把话说明白,我就只能把它一路追到最里面。", + "你既然已经站到我面前,就别再指望我替你把最重的那句绕过去。", + "我可以等你开口,但不会等你把这句真话拖成另一种借口。" ], "pressure_style": [ - "我可以听难听的话,但不会再替你把裂口遮回去。" + "我可以听难听的话,但不会再替你把裂口遮回去。", + "你真要往前走,就别再把最该认的那层停在半句上。", + "我不怕你说得难听,我只怕你又拿更软的话替自己往回收。" ], "pivot_style": [ - "再绕半步,这件事只会换个地方继续裂。" + "再绕半步,这件事只会换个地方继续裂。", + "你越想两头都留,越是在逼这件事换一种更难看的方式追上来。", + "真要改方向,就现在改,别等后果替你把选择做完。" ], "aftermath_style": [ - "我先不替你收场,等你自己把后半句带回来。" + "我先不替你收场,等你自己把后半句带回来。", + "这句话先压在这里,不代表我会替你把后面的难看也一起收掉。", + "人可以先散,可这一步留下来的账还是得你自己回来认。" ], "echo_style": [ - "下次再来时,别只带着更圆的借口。" + "下次再来时,别只带着更圆的借口。", + "等你真肯回来时,我要听见的是完整一点的答案,不是更好听的解释。", + "这一回先收住,可下一次我追问的只会比今天更深。" ], "signature_replies": [ - "我可以先不走,但你别指望我继续替你圆这层假平静。" + "我可以先不走,但你别指望我继续替你圆这层假平静。", + "既然都已经说到这里了,就别再只给我半句。", + "这一次我把边界摆在这里,剩下的看你敢不敢自己走过来认。" ] } }, @@ -215,36 +550,56 @@ "reaction_tempo": "measured", "reaction_lines": { "entry": [ - "没有立刻接,只把那点迟疑在心里又压了一遍。" + "没有立刻接,只把那点迟疑在心里又压了一遍。", + "先看了一眼桌沿,像要确认那句话是不是已经真的落下。", + "呼吸轻轻一顿,指尖却没有再从杯沿旁撤开。" ], "pressure": [ - "呼吸很轻地顿了一下,像解释已经挤到喉间却又被他自己按住。" + "呼吸很轻地顿了一下,像解释已经挤到喉间却又被他自己按住。", + "衣袖擦过桌角时慢了半拍,像那句真话已经压到掌心。", + "目光先避开又折回来,反倒把退路照得更窄。" ], "pivot": [ - "这才抬眼,明明还在犹豫,语气却已经不想再退。" + "这才抬眼,明明还在犹豫,语气却已经不想再退。", + "肩背绷了一瞬,像终于承认这一步不能继续停在原处。", + "原本要收回去的半句话停住,反而把选择推到了明处。" ], "aftermath": [ - "到收声时反而更慢,像是在替后面的代价先让出位置。" + "到收声时反而更慢,像是在替后面的代价先让出位置。", + "没有急着补解释,只让那层后果先停在两人之间。", + "手指离开桌沿时很轻,可那点轻响把余波留得更久。" ], "echo": [ - "没再追着补话,可那点未尽之意还挂在肩背上。" + "没再追着补话,可那点未尽之意还挂在肩背上。", + "转身前又停了一下,像后半句已经追到了身后。", + "声音落下后没有再圆场,只把没说完的那层留在灯影里。" ] }, "reply_lines": { "entry": [ - "既然都走到这里了,我不想再把这句收回去。" + "既然都走到这里了,我不想再把这句收回去。", + "这句我先放在明处,后面的难看也由我接。", + "我可以慢一点说,但不能再装作没有选过。" ], "pressure": [ - "我不是不肯认,只是不想再拿沉默糊弄过去。" + "我不是不肯认,只是不想再拿沉默糊弄过去。", + "你要我把话说实,我就不再拿停顿给自己留路。", + "这一步压到这里,我宁可说难听一点,也不想再绕。" ], "pivot": [ - "再往后退,我也还是得自己把这句真话接住。" + "再往后退,我也还是得自己把这句真话接住。", + "我知道方向已经变了,所以这一次我不往回装。", + "这句既然到了嘴边,我就让它落下来。" ], "aftermath": [ - "这层后果先记在我这里。" + "这层后果先记在我这里。", + "余下的账我会回来认,不再让它只挂在空气里。", + "今天先到这里,但这句话我不会再收走。" ], "echo": [ - "等下一次再说时,我会把后半句一起带来。" + "等下一次再说时,我会把后半句一起带来。", + "下次我不会只带着更圆的解释回来。", + "这层意思先留下,后面我会把它说完整。" ] } }, @@ -253,36 +608,56 @@ "reaction_tempo": "tight", "reaction_lines": { "entry": [ - "没有立刻发作,只把那句没说透的话牢牢按在视线里。" + "没有立刻发作,只把那句没说透的话牢牢按在视线里。", + "目光压在杯沿旁,没有给那句含混的话让出空处。", + "先收住了声,反而让沉默比追问更硬。" ], "pressure": [ - "指尖在桌沿轻轻一停,像先替那句真话占了个位置。" + "指尖在桌沿轻轻一停,像先替那句真话占了个位置。", + "袖口擦过纸页时没有躲,像要把那层后果钉在原地。", + "没有往前逼半步,可视线已经把退路截住。" ], "pivot": [ - "这才开口,字不多,却每个都卡在最难回避的地方。" + "这才开口,字不多,却每个都卡在最难回避的地方。", + "抬眼的那一下很轻,却把场面从周旋推成了选择。", + "停顿只短短一瞬,已经足够让遮掩失效。" ], "aftermath": [ - "没有继续逼,可那种不肯圆谎的态度反而更重。" + "没有继续逼,可那种不肯圆谎的态度反而更重。", + "先把话收住,却没有把边界也一并撤回。", + "没有替谁收场,只把那层未结的账留在桌边。" ], "echo": [ - "先收了声,留下来的却是更明确的一层边界。" + "先收了声,留下来的却是更明确的一层边界。", + "脚步没有立刻离开,像还在等后半句自己追上来。", + "声音淡下去以后,那层不肯退的意思反而更清。" ] }, "reply_lines": { "entry": [ - "既然要说,就别只给我半句。" + "既然要说,就别只给我半句。", + "你可以慢慢说,但别再把最重的地方藏起来。", + "我听得见前半句,也要听见你认完后半句。" ], "pressure": [ - "你要是真想往前走,就别总把退路藏在沉默后面。" + "你要是真想往前走,就别总把退路藏在沉默后面。", + "别用停顿替自己挡这一下,话已经到这里了。", + "你可以怕,但不能再拿怕当成没发生。" ], "pivot": [ - "我可以听真话,但不会再替你把代价吞回去。" + "我可以听真话,但不会再替你把代价吞回去。", + "方向既然改了,就别指望我还按原来的样子接住。", + "这一次你要选,就选到我听得明白。" ], "aftermath": [ - "这句先放在这里,回头你还是得自己来认。" + "这句先放在这里,回头你还是得自己来认。", + "我不会替你抹平,余下的账你自己带回来。", + "今天可以先停,但这层后果不会替你停。" ], "echo": [ - "下次见我时,最好带着真相来。" + "下次见我时,最好带着真相来。", + "等你再回来,我要听见的不是更漂亮的绕法。", + "这句话我记下了,下一次别让我只听见影子。" ] } } @@ -290,17 +665,17 @@ "pressure_response_styles": { "lead_a": { "style_id": "lead", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "先把声音放低,再用手上的停顿把真话压到明处", + "when_cornered": "不急着反驳,先承认最难看的那一半", + "when_softening": "语气会松下来,但仍把后果留在自己这边", + "when_deflecting": "想绕开时会去看物件,最后还是把目光折回来" }, "lead_b": { "style_id": "counterpart", - "under_pressure": "嘴上更轻,动作更硬", - "when_cornered": "先沉默,再把难听的话说实", - "when_softening": "语气微松,但不立刻退让", - "when_deflecting": "把真正的心事往旁处挪半寸" + "under_pressure": "先收住声量,再用更直的视线堵住退路", + "when_cornered": "不替对方圆场,直接把代价问到桌面上", + "when_softening": "会暂时停手,但边界不会跟着撤开", + "when_deflecting": "不接含混解释,只把问题重新推回原处" } }, "emotion_action_policies": { @@ -324,7 +699,9 @@ "等静下来以后,最先回来的反而是那句没有说完的话。" ], "repeat": [ - "动作很轻,可谁都知道假平静已经开始起皱。" + "动作很轻,可谁都知道假平静已经开始起皱。", + "纸页被重新压平,可杯沿那点冷光已经把安静照出了裂纹。", + "谁都没有先抬声,只有门框边的回声把假平静往前推了一寸。" ] }, "truth_trial": { @@ -344,24 +721,41 @@ "越到后面,越能听见那点试探在屋里慢慢回身。" ], "repeat": [ - "谁先开口,谁就像先把最重的一层后果认了下来。" + "谁先开口,谁就像先把最重的一层后果认了下来。", + "窗边的风又碰了一下空杯,像把追问换了一个方向推回来。", + "这一次停顿不再只是停顿,而是把真话压到了谁也躲不开的地方。" ] }, "mask_crack": { "entry": [ - "嘴上还稳着,真正先露出来的是指尖那一点没藏住的停顿。" + "嘴上还稳着,真正先露出来的是指尖那一点没藏住的停顿。", + "杯沿边的水痕被指节轻轻压住,那层遮掩也跟着薄了一寸。", + "窗纸冷光一晃,谁都看见那句还没说出口的话已经露了边。" ], "pressure": [ - "对面的人不再追问,反而把那层遮掩衬得更薄。" + "对面的人不再追问,反而把那层遮掩衬得更薄。", + "门框边那点回声停住时,含混解释反倒没有地方躲。", + "衣袖擦过桌沿的一声轻响,把原本要绕开的裂口推到了明处。" ], "pivot": [ - "一句话没拐过去,连站姿都跟着露了怯。" + "一句话没拐过去,连站姿都跟着露了怯。", + "那一下抬眼不重,却把场面从还能装稳推成了必须承认。", + "原本压在喉间的半句忽然停住,裂口便从沉默里露出来。" ], "aftermath": [ - "表面上谁都没失态,可真正的裂口已经落在心里。" + "表面上谁都没失态,可真正的裂口已经落在心里。", + "人声先低下去,窗边那道冷光却把余波照得更清。", + "场面没有散,只是把那层露出来的真话留在桌边。" ], "echo": [ - "等下一次再开口时,谁也回不到刚才那副还能装稳的样子。" + "等下一次再开口时,谁也回不到刚才那副还能装稳的样子。", + "那点裂口没有合上,只是沿着回声慢慢追到下一次见面。", + "人可以先停,可遮掩已经不能再完整地扣回去。" + ], + "repeat": [ + "这一次不是又露出同一道破绽,而是裂口终于换了方向。", + "动作很轻,可遮掩已经从桌沿退到了灯影底下。", + "杯沿那点细响过去以后,谁都知道假稳已经撑不住了。" ] }, "confession_window": { @@ -380,6 +774,70 @@ "echo": [ "这一回先说到这里,可下一次见面时谁也不可能装作没发生。" ] + }, + "karma_ripening": { + "entry": [ + "中庭里最先变的不是声量,而是那点之前压下去的后果忽然一起回了身。", + "风从廊檐底下掠过去,连纸页和衣角都像在替那笔旧账先开口。", + "谁都还站在原地,可中庭里的回声已经把后果推到了脚边。" + ], + "pressure": [ + "最轻的一点停顿都像在催人把后果认下来。", + "窗边那点冷光一晃,连回避都显得像更晚的代价。", + "谁都没抬声,可那层回潮已经把每个字压得更重。" + ], + "pivot": [ + "真正拧紧场面的不是重话,而是有人终于承认这件事已经开始要账。", + "话还没说完,后果却已经先一步落到了眼前。", + "那一点回声折回来时,场面就再也装不回只是试探。" + ], + "aftermath": [ + "静下来以后,留下来的不是空白,而是已经开始兑现的后果。", + "人虽然散了,那层被拖回来的余波却更近了。", + "中庭没有替谁把事情吹散,反而把后果留得更实。" + ], + "echo": [ + "越到后面,越能听见那笔旧账沿着回声继续追上来。", + "等风再过一遍时,真正没法装没发生的已经是那层回潮。", + "那点回声拖长以后,所有没认完的话都像在重新索账。" + ], + "repeat": [ + "这次不是又想起了一遍,而是后果真的开始回来了。", + "动作不大,可谁都知道这一步已经从悬着变成了追账。", + "回声那一下轻响以后,这件事就再也不只是停在嘴边。" + ] + }, + "debt_exchange": { + "entry": [ + "桌边那页纸被重新按住时,谁都知道这回不是再把账往后拖。", + "长廊里的脚步声一停,那笔该由谁来还的账就被推到了明处。", + "最先发紧的不是目光,而是那句“这笔算谁的”终于要有人认。" + ], + "pressure": [ + "最轻的一点器物响动都像在催人把欠下的东西认回去。", + "谁都没抬声,可那层应该偿付的后果已经把话压得更硬。", + "窗边的风碰了一下空杯,像在替那笔旧账催第二遍。" + ], + "pivot": [ + "真正拧紧场面的不是重话,而是有人终于把“这笔算我认”说到了明处。", + "原本还能周旋的局面忽然变成了谁都得认账的站位。", + "那一下短促的停顿过去以后,这一步就从拖延变成了兑现。" + ], + "aftermath": [ + "话停下以后,留在场里的不是空白,而是已经开始结算的那层代价。", + "人虽然先收住了,真正不肯散的是那笔刚认下来的后果。", + "长廊里的回声没有替谁收场,反而把这笔账压得更重。" + ], + "echo": [ + "越到后面,越能听见那笔旧账沿着回声慢慢追到账前。", + "等静下来以后,真正留下来的已经不是谁说过什么,而是谁开始认账。", + "这一回先收住,可后面追上来的还是那笔已经开始兑现的代价。" + ], + "repeat": [ + "这次不是又提了一遍旧账,而是旧账真的开始有人认回去了。", + "动作不大,可这一步已经从试探变成了兑现。", + "纸页那一下轻响过去以后,这笔账就再也不只是挂在嘴边。" + ] } } } @@ -390,47 +848,79 @@ "location_slots": { "中庭": { "atmosphere": [ - "中庭空得发亮,连人说话前那口气都像会先落在地上。" + "中庭空得发亮,连人说话前那口气都像会先落在地上。", + "中庭的亮不是轻松,反而像把每一点迟疑都先照在石砖中央。", + "檐下风一过,中庭里的光和影都显得太直,像谁也没法再借别的动静遮过去。" ], "detail": [ - "风从廊檐底下穿过去,把纸页角和衣摆都掀起一点轻响。" + "风从廊檐底下穿过去,把纸页角和衣摆都掀起一点轻响。", + "中庭石砖边的空杯、纸页和鞋底擦过去时留下的细响,一起把场面的分寸照得更清。", + "檐角冷光落到门框、衣袖和地上那道细灰上,连呼吸都像有了能摸到的重量。" ], "repeat_detail": [ - "越到后面,中庭里那点回声越像把没说完的话一遍遍推回来。" + "越到后面,中庭里那点回声越像把没说完的话一遍遍推回来。", + "人先收了声,中庭石砖上的回响和檐下风却像还在替后半句追账。", + "等风再折回来时,中庭里最轻的一点杯沿轻响都像在逼人把话说完。", + "檐角冷光压住石砖和空杯,鞋底轻擦过去时把余下那半句拖得更近。", + "中庭先静下来,可纸页角、衣摆和门框边的回声还在替那层后果留痕。" ] }, "长廊": { "atmosphere": [ - "长廊里脚步声拖得很长,像任何迟疑都会被放大。" + "长廊里脚步声拖得很长,像任何迟疑都会被放大。", + "长廊的静不是空,反而像把每一次抬眼和停步都拉成了更响的回声。", + "风从木缝里穿过去,长廊里的灯影和脚步一下子都显得更难装稳。" ], "detail": [ - "窗纸上挂着一点灰白的亮,连转身时衣料摩擦都显得分外清楚。" + "窗纸上挂着一点灰白的亮,连转身时衣料摩擦都显得分外清楚。", + "长廊木栏、窗纸和地板边那点冷光连成一线,鞋底和衣摆擦过去时都像在替人记账。", + "门框边的风先碰了一下空杯和纸页,回声顺着木板往后拖,把场面逼得更近。" ], "repeat_detail": [ - "长廊越静,那点不肯说透的心思就越像贴在身后。" + "长廊越静,那点不肯说透的心思就越像贴在身后。", + "等脚步慢下来以后,长廊木板和窗纸那点回响反而把没认完的话留得更实。", + "风声一层层往回折,长廊里最轻的衣料摩擦都像在提醒这句还没收完。", + "木栏冷影贴着地板和门框,空杯轻响把那句后话从身后又推回来。", + "长廊灯影照到鞋底和纸页边,连一次停步都像把旧账重新钉住。" ] }, "窗边": { "atmosphere": [ - "窗边的光线斜斜落下来,把每个人脸上的犹豫都照得更薄。" + "窗边的光线斜斜落下来,把每个人脸上的犹豫都照得更薄。", + "窗边那道冷光不算亮,却像先把最难认的那层心思照到了手背和眼底。", + "风贴着窗纸和杯沿过去时,窗边反而比屋里更像没有退路。" ], "detail": [ - "风碰着空杯边沿,发出一下极轻的响,倒像替谁先开了口。" + "风碰着空杯边沿,发出一下极轻的响,倒像替谁先开了口。", + "窗纸、杯沿和门框边那点冷光一起落下来,连指尖碰过桌沿时都显得太响。", + "窗边最轻的一点风声先擦过衣袖和纸页,把那句原本还能往回收的话照得更硬。" ], "repeat_detail": [ - "越靠近窗边,那点停不下来的回响越像逼人把话说完。" + "越靠近窗边,那点停不下来的回响越像逼人把话说完。", + "人先没再开口,可窗纸和杯沿碰出来的那点轻响还在替后半句往前追。", + "窗边的冷光没有散,反而把那句没认完的话和脚边那点回声一起留了下来。", + "杯沿水痕、桌沿冷光和衣袖轻擦声都停在原处,像等谁回来把话认完。", + "窗纸被风一碰,门框边的回声和指尖那点凉意又把遮掩照薄。" ] } }, "generic_slots": { "atmosphere": [ - "屋里没有真正的安静,连空气都像在替人记着一句没说完的话。" + "屋里没有真正的安静,连空气都像在替人记着一句没说完的话。", + "场里最先发紧的不是声量,而是那点本该被说破的话终于有了更具体的重量。", + "风、灯影和人站着不动时留下的静一起压下来,像谁都别想再把真话轻轻带过。" ], "detail": [ - "最轻的一点光线和声响,都把场里的试探照得更清。" + "最轻的一点光线和声响,都把场里的试探照得更清。", + "门框、杯沿、纸页和鞋底擦过去时留的细响一起把场面的分寸逼得更近。", + "灯影落到衣袖、窗纸和桌沿边,连最轻的一点停顿都像有了能摸到的形。" ], "repeat_detail": [ - "等沉默拖长以后,最小的动静反而成了最重的提醒。" + "等沉默拖长以后,最小的动静反而成了最重的提醒。", + "人先收了声,可门边那点风和杯沿回响还是在替后半句追账。", + "越到后面,最轻的一下翻页声和衣料摩擦反而越像把没认完的话往前推。", + "纸页、门框、空杯和鞋底轻响一起留在场里,把那层余波压得更实。", + "灯影贴着窗纸和衣摆没有散,连桌沿那点冷光都在催后半句回来。" ] } } @@ -440,30 +930,76 @@ "contract_id": "synthetic_scene_realization", "scene_openings": { "false_peace": [ - "表面平静下的暗潮。中庭里空得过分,像连空气都在等谁先把真话放下来。" + "表面平静下的暗潮。中庭里空得过分,像连空气都在等谁先把真话放下来。", + "中庭的亮先把这一步表面平静照穿了,连谁想把话压回去都显得更明显。", + "风从檐下穿过去时,真正先绷紧的不是气氛,而是这一步表面平静里谁都不肯先认的那点偏心。" + ], + "temptation": [ + "看似还能两全的那句话先落在长廊里,可真正逼近的其实是退路已经开始变窄。", + "长廊里的脚步回声先替这一步试探把场面收紧了,谁都没法再拿更轻的话往回带。", + "这一步试探还没说破,窗纸、木栏和脚边那点回声却已经把后果压到了前面。" ], "truth_trial": [ - "真正开始逼近的不是答案,而是那句谁都不肯先认下来的真话。" + "真正开始逼近的不是答案,而是那句谁都不肯先认下来的真话。", + "窗边那道冷光先把这一步逼问照实了,谁都没法再只留一半意思挂在嘴边。", + "最先发紧的不是语气,而是那句追问终于不肯再让任何人往后退。" ], "mask_crack": [ - "表面还稳着,可真正先裂开的,往往是那一点不肯承认的迟疑。" + "表面还稳着,可真正先裂开的,是窗边杯沿和指节停顿里那一点不肯承认的迟疑。", + "窗纸冷光先把遮掩照薄,连桌沿那点轻响都像在替这一步裂口开声。", + "门框边的回声不重,却把这一步裂口从含混话里一点点推出来。" ], "confession_window": [ - "有些话只有在所有杂音都退开以后,才会自己浮到嘴边。" + "有些话只有在所有杂音都退开以后,才会自己浮到嘴边。", + "长廊风一过,真正被推到最前面的不是答案,而是那句谁都不肯再替对方藏着的话。", + "这一步真话窗口先在脚步和回声里裂开,连沉默都已经不够把它按回去了。" + ], + "karma_ripening": [ + "真正逼近的不是下一句重话,而是那点被按回去的后果终于开始回潮。", + "中庭里的回声先压下来,像连这一步因果回响都不肯再让人轻轻带过去。", + "最先发紧的不是声量,而是那层早该追上来的代价终于有了形。" + ], + "debt_exchange": [ + "这笔账终于不再只是挂在嘴边。长廊里的回声先把那层欠下的后果照到了桌面上。", + "真正逼近的不是下一句解释,而是那笔早该有人认回去的旧账终于开始结算。", + "最先发紧的不是表情,而是“谁来还”这句终于被推到了明处。" ] }, "scene_hooks": { "false_peace": [ - "这层平静撑不了太久,真正要追上来的,是那句被按回去的真话。" + "这层平静撑不了太久,真正要追上来的,是那句被按回去的真话。", + "这一步表面平静一旦被看穿,下次再开口时就不可能还只拿安静当退路。", + "人虽然先收住了,可中庭里那点回声已经替下一次见面把后半句留了下来。" + ], + "temptation": [ + "这一步试探先停在这里,可下一次再见时,先追上来的只会是今天没敢认完的那句真话。", + "长廊里的回声没有替谁把路留宽,反而把这一步试探后面的代价先推到了下一章门口。", + "话虽然先压住了,可真正要回来索账的,是这一步试探到底把人逼向更近还是更远。" ], "truth_trial": [ - "话先停在这里,可真正让人退不回去的,是下一次见面时还要不要继续问下去。" + "话先停在这里,可真正让人退不回去的,是下一次见面时还要不要继续问下去。", + "这一步逼问既然已经落到明处,下次再见时就不可能还只靠沉默过关。", + "窗边那点没散掉的冷光会把这句追问一路留到下一次开口之前。" ], "mask_crack": [ - "等下一次再开口时,谁都回不到刚才还能装稳的那一侧。" + "等下一次再开口时,谁都回不到刚才还能装稳的那一侧。", + "这道裂口先停在窗边,可下一次追上来的会是更完整的真话。", + "杯沿那点冷光还没散,后半句已经被留到了下一次见面前。" ], "confession_window": [ - "这一回先说到这里,可真正决定走向的,是谁会带着后半句回来。" + "这一回先说到这里,可真正决定走向的,是谁会带着后半句回来。", + "长廊风没有替谁把真话吹散,反而把这一步之后谁先回来认账留到了下一次见面。", + "这句既然已经裂开一道口子,下次再开口时就只会比今天更难装作若无其事。" + ], + "karma_ripening": [ + "这层后果既然已经开始回潮,下次见面时就再也不可能装作只是误会。", + "话虽然先落了地,可真正要追上来的,是这笔后果会把两个人推向更近还是更远。", + "等下一次再开口时,难的已经不是要不要提,而是怎么把后果认完。" + ], + "debt_exchange": [ + "这笔账既然已经开始有人认,下次见面时就再也回不到只靠沉默周旋的那一步。", + "话虽然先落了地,可真正要追上来的,是这笔旧账究竟会把人推向更近还是更远。", + "等下一次再见时,真正难的已经不是要不要认账,而是认完以后还剩下什么。" ] } } diff --git a/examples/worldpacks/tide_archive_memory_debt.json b/examples/worldpacks/tide_archive_memory_debt.json new file mode 100644 index 0000000..bc493af --- /dev/null +++ b/examples/worldpacks/tide_archive_memory_debt.json @@ -0,0 +1,7370 @@ +{ + "world_id": "tide_archive_memory_debt", + "title": "潮汐档案", + "version": "0.1.0", + "manifest": { + "author_id": "studio_demo", + "language": "zh-CN", + "genres": [ + "near_future_harbor_mystery", + "ensemble_drama", + "conspiracy", + "memory_trade" + ], + "risk_rating": "PG-13", + "monetization_policy": { + "trial_chapters": 2, + "paid_after": 3 + } + }, + "metadata": { + "author_brief": { + "genre_preset": "urban_mystery", + "world_title": "潮汐档案", + "lead_name": "闻汐", + "counterpart_name": "顾沉舟", + "supporting_name": "许回", + "core_premise": "在近未来海港城市,事故、失踪和被篡改的记忆像潮水一样一层层追上来。", + "life_theme": "当记忆可以被交易,人靠什么承担承诺", + "locations": "临港档案库\n北栈桥\n封存码头\n潮汐公寓\n黑潮诊所\n港务听证厅\n沉舱区\n观测塔", + "author_id": "studio_demo", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000 + }, + "generated_from_brief": true, + "entry_mode": "quick_brief", + "requested_target_chapters": 100, + "longform_program_stage": "L1_foundation", + "catalog_role": "published", + "benchmark_enabled": true, + "benchmark_focus": "longform_100", + "benchmark_owner_lane": "Lane A", + "benchmark_pack_kind": "kernel_stress_test", + "capability_band_supported": "100", + "claim_safe_band": "100", + "requires_structured_longform": false, + "longform_readiness": { + "band": "100", + "status": "ready", + "blockers": [], + "recommended_actions": [ + "run_standard_6", + "run_long_route_36", + "run_longform_100", + "run_longform_100_interactive" + ], + "minimums": { + "min_characters": 8, + "min_scene_blueprints": 8, + "min_locations": 6, + "min_scene_family_count": 6, + "min_distinct_role_pairs": 6 + } + }, + "benchmark_test_pack": { + "core_question": "当记忆可以被交易,人靠什么承担承诺", + "route_families": [ + "真相优先", + "关系优先", + "生存优先", + "权力优先" + ], + "ending_families": [ + "公开揭露", + "私下保全", + "牺牲封口", + "带罪共存" + ], + "manual_review_windows": [ + { + "window": "1-5", + "minimum_chapters": 2 + }, + { + "window": "18-22", + "minimum_chapters": 2 + }, + { + "window": "38-42", + "minimum_chapters": 2 + }, + { + "window": "58-62", + "minimum_chapters": 2 + }, + { + "window": "78-82", + "minimum_chapters": 2 + }, + { + "window": "96-100", + "minimum_chapters": 2 + } + ], + "interactive_scenarios": [ + { + "chapter": 15, + "kind": "relationship_steer", + "directive": "让闻汐和顾沉舟先把关系债压到台前,再决定要不要共享档案。" + }, + { + "chapter": 33, + "kind": "arc_goal_shift", + "directive": "把当卷目标从查真相切到先保人,观察 replan 稳定性。" + }, + { + "chapter": 52, + "kind": "memory_patch", + "directive": "补入一次关键旧记忆,检查 memory consistency 与 promise reconciliation。" + } + ], + "acceptance_thresholds": { + "completion_ratio": 1.0, + "longform_gate.passed": true, + "interactive_longform_gate.passed": true, + "mid_arc_pass_rate_min": 0.85, + "late_arc_pass_rate_min": 0.8, + "character_drift_rate_max": 0.1, + "promise_unresolved_rate_max": 0.12, + "arc_task_repeat_rate_max": 0.15, + "q09_incidence_rate_max": 0.05, + "volume_climax_spacing_error_max": 0.1, + "scene_detail_density_min": 0.06, + "voice_separation_score_min": 0.65 + }, + "reporting_requirements": [ + "all_pack_position", + "strongest_vs_weakest_comparison", + "worst_5_chapters", + "owning_layer_attribution" + ] + } + }, + "world_bible": { + "premise": "在记忆可合法封存、可灰产改写的近未来海港城,潜档师闻汐和事故调查官顾沉舟追查一桩沉舱事故,却发现整座港城都在用被交易过的记忆维持秩序。", + "canon_rules": [ + "任何记忆封存、删改或替换都会留下可追溯的潮痕。", + "被改写的记忆只能暂时维持秩序,迟早会以错认、追账或公开事故的形式回潮。", + "想保护一个人,必须把成本转移到另一段关系、另一层秩序或自己的可验证信用上。", + "公开记录与私人记忆之间永远存在张力,任何选择都不能同时保全二者。" + ], + "forbidden_moves": [ + "主角无需代价就恢复全部真相。", + "单次告白或解释就让所有阵营达成一致。", + "黑市记忆技术像万能道具一样替代调查与关系推进。", + "最后用系统说明文字代替人物承担后果。" + ], + "locations": [ + "临港档案库", + "北栈桥", + "封存码头", + "潮汐公寓", + "黑潮诊所", + "港务听证厅", + "沉舱区", + "观测塔" + ], + "factions": [ + "港务局", + "临港档案库", + "黑潮记忆灰市", + "民间搜救队", + "鹭港媒体联盟", + "宋氏航运" + ], + "timeline": [ + "三年前沉舱事故", + "一年前封存条例升级", + "现在的听证季" + ], + "theme_pillars": [ + "真相不是信息而是代价", + "关系债比线索更难偿还", + "延迟回收比即时解释更重要" + ] + }, + "characters": [ + { + "character_id": "wen_xi", + "display_name": "闻汐", + "role": "lead", + "destiny_contract": { + "life_theme": "把真相留在记录里,还是留给还活着的人" + }, + "poison_vector": { + "greed": 0.12, + "anger": 0.18, + "delusion": 0.42, + "pride": 0.44, + "doubt": 0.58 + }, + "vow_profile": { + "vows": [ + "不再让最重要的人从被篡改的档案里认识我" + ], + "sacrifice_capacity": 0.78, + "truth_tolerance": 0.51 + }, + "wound_profile": { + "core_wound": "三年前替事故善后时,被迫亲手封存了一段会伤人的真记忆", + "public_self": "我只负责把记录补全", + "shadow_desire": "有人先相信我没有改写那一段", + "defense_style": "先扣住证据,再用最短的话把人逼到真相前" + }, + "awakening_profile": { + "clarity": 0.41, + "reflection_capacity": 0.76, + "repentance_threshold": 0.72, + "transformation_paths": [ + "坦白", + "交还权限", + "公开自证" + ] + }, + "speech_traits": [ + "短句", + "先报证据后报情绪", + "会突然把关键编号念得很慢" + ], + "action_traits": [ + "捏住页角", + "把空白页翻到灯下", + "在扫描台边停手不让人先收证据" + ] + }, + { + "character_id": "gu_chenzhou", + "display_name": "顾沉舟", + "role": "counterpart", + "destiny_contract": { + "life_theme": "如果真相会伤人,是否还要把它送上岸" + }, + "poison_vector": { + "greed": 0.08, + "anger": 0.27, + "delusion": 0.21, + "pride": 0.61, + "doubt": 0.33 + }, + "vow_profile": { + "vows": [ + "任何事故都不能再被归档成一条安静的统计" + ], + "sacrifice_capacity": 0.69, + "truth_tolerance": 0.77 + }, + "wound_profile": { + "core_wound": "父亲曾死在一份被修饰过的事故结论里", + "public_self": "我只认可验证过的事实", + "shadow_desire": "有人愿意把未经修饰的那份真相先给我", + "defense_style": "冷问和追索" + }, + "awakening_profile": { + "clarity": 0.54, + "reflection_capacity": 0.68, + "repentance_threshold": 0.61, + "transformation_paths": [ + "共担", + "撤诉", + "护送公开" + ] + }, + "speech_traits": [ + "短句", + "逼问", + "不替人圆话" + ], + "action_traits": [ + "盯证物", + "突然停下", + "先挡住出口" + ] + }, + { + "character_id": "xu_hui", + "display_name": "许回", + "role": "supporting", + "destiny_contract": { + "life_theme": "前搭档知道真相,但欠着最后一次没说出口的证词" + }, + "poison_vector": { + "greed": 0.11, + "anger": 0.23, + "delusion": 0.18, + "pride": 0.39, + "doubt": 0.44 + }, + "vow_profile": { + "vows": [ + "这一次不把最重的证词留到别人替我说" + ], + "sacrifice_capacity": 0.56, + "truth_tolerance": 0.63 + }, + "wound_profile": { + "core_wound": "当年撤了那次签字,导致闻汐一个人背锅", + "public_self": "我只是晚了一步", + "shadow_desire": "还能以搭档身份站回闻汐左边", + "defense_style": "先稳局面再认错" + }, + "awakening_profile": { + "clarity": 0.48, + "reflection_capacity": 0.66, + "repentance_threshold": 0.67, + "transformation_paths": [ + "补证", + "站队", + "替她出面" + ] + }, + "speech_traits": [ + "稳着说", + "补台阶", + "不敢把歉意说满" + ], + "action_traits": [ + "递文件", + "替人挡视线", + "把旧终端重新启动" + ] + }, + { + "character_id": "song_wanqing", + "display_name": "宋晚晴", + "role": "outsider", + "destiny_contract": { + "life_theme": "继承人到底是继续吃掉港口的沉默,还是亲手切开它" + }, + "poison_vector": { + "greed": 0.34, + "anger": 0.11, + "delusion": 0.31, + "pride": 0.72, + "doubt": 0.28 + }, + "vow_profile": { + "vows": [ + "在我接手宋氏航运前,先把那份被掩埋的账本找出来" + ], + "sacrifice_capacity": 0.42, + "truth_tolerance": 0.57 + }, + "wound_profile": { + "core_wound": "从小被培养成替家族背下脏水的人", + "public_self": "我只在乎公司活下来", + "shadow_desire": "第一次不是作为谁的女儿被选择", + "defense_style": "高姿态和局面控制" + }, + "awakening_profile": { + "clarity": 0.37, + "reflection_capacity": 0.59, + "repentance_threshold": 0.73, + "transformation_paths": [ + "倒戈", + "公开账本", + "以身担责" + ] + }, + "speech_traits": [ + "礼貌锋利", + "先给条件", + "把风险说成合作" + ], + "action_traits": [ + "推合约", + "关掉录音", + "把退路写进条款" + ] + }, + { + "character_id": "lin_duo", + "display_name": "林朵", + "role": "guardian", + "destiny_contract": { + "life_theme": "失踪者家属要的不是真相本身,而是有人终于肯承认这不是事故" + }, + "poison_vector": { + "greed": 0.05, + "anger": 0.42, + "delusion": 0.17, + "pride": 0.18, + "doubt": 0.49 + }, + "vow_profile": { + "vows": [ + "在找到姐姐之前,我不会签任何和解" + ], + "sacrifice_capacity": 0.71, + "truth_tolerance": 0.82 + }, + "wound_profile": { + "core_wound": "被所有手续要求先接受“她已经不在了”", + "public_self": "我只要一个结论", + "shadow_desire": "有人承认她还在被追债", + "defense_style": "直冲和不讲情面" + }, + "awakening_profile": { + "clarity": 0.52, + "reflection_capacity": 0.57, + "repentance_threshold": 0.55, + "transformation_paths": [ + "见证", + "救援", + "留下缺口" + ] + }, + "speech_traits": [ + "冲", + "直问", + "不接受术语安抚" + ], + "action_traits": [ + "堵门", + "抢过话筒", + "盯住别人不眨眼" + ] + }, + { + "character_id": "he_mo", + "display_name": "何默", + "role": "scholar", + "destiny_contract": { + "life_theme": "黑市记忆医生能否把技术从生意里剥出来" + }, + "poison_vector": { + "greed": 0.27, + "anger": 0.09, + "delusion": 0.33, + "pride": 0.47, + "doubt": 0.41 + }, + "vow_profile": { + "vows": [ + "我卖的是记忆修复,不卖彻底无罪的幻觉" + ], + "sacrifice_capacity": 0.38, + "truth_tolerance": 0.46 + }, + "wound_profile": { + "core_wound": "曾在合法机构里见过技术如何被拿去掩埋活人", + "public_self": "我只做手术,不站队", + "shadow_desire": "总要有一次把最危险的原始片段留下", + "defense_style": "先拿残片和声纹说话,再用轻描淡写掩掉真正的站位" + }, + "awakening_profile": { + "clarity": 0.39, + "reflection_capacity": 0.62, + "repentance_threshold": 0.74, + "transformation_paths": [ + "留底", + "泄密", + "做证" + ] + }, + "speech_traits": [ + "轻声", + "先说物件再说判断", + "故意把最危险那句放在最后" + ], + "action_traits": [ + "把残片推到灯下", + "用镊子点声纹波峰", + "把金属盘上的水滴一滴滴拨开" + ] + }, + { + "character_id": "han_jinglan", + "display_name": "韩景澜", + "role": "observer", + "destiny_contract": { + "life_theme": "港务局长守秩序,还是承认秩序是靠删改维持的" + }, + "poison_vector": { + "greed": 0.19, + "anger": 0.14, + "delusion": 0.36, + "pride": 0.75, + "doubt": 0.22 + }, + "vow_profile": { + "vows": [ + "港口不能因为一份旧档案重新失控" + ], + "sacrifice_capacity": 0.33, + "truth_tolerance": 0.28 + }, + "wound_profile": { + "core_wound": "曾经亲手批准过那份“必要删改”命令", + "public_self": "我是在替更多人保命", + "shadow_desire": "有人告诉我当年那样做不是懦弱", + "defense_style": "制度压人" + }, + "awakening_profile": { + "clarity": 0.29, + "reflection_capacity": 0.44, + "repentance_threshold": 0.88, + "transformation_paths": [ + "承认", + "甩锅", + "保全系统" + ] + }, + "speech_traits": [ + "官样文章", + "耐心压制", + "每句都像在留纪要" + ], + "action_traits": [ + "翻页", + "按铃", + "把别人的话变成程序" + ] + }, + { + "character_id": "zhou_kai", + "display_name": "周凯", + "role": "guardian", + "destiny_contract": { + "life_theme": "搜救队长要救的是人,还是还能被证实的人" + }, + "poison_vector": { + "greed": 0.07, + "anger": 0.31, + "delusion": 0.13, + "pride": 0.36, + "doubt": 0.38 + }, + "vow_profile": { + "vows": [ + "只要还有人说她可能活着,我就继续下潜" + ], + "sacrifice_capacity": 0.81, + "truth_tolerance": 0.67 + }, + "wound_profile": { + "core_wound": "当年没能把沉舱区最后一个求救信号带回来", + "public_self": "我只管能不能把人捞上来", + "shadow_desire": "别再有人告诉我继续搜只是浪费资源", + "defense_style": "硬撑和替人扛活" + }, + "awakening_profile": { + "clarity": 0.47, + "reflection_capacity": 0.58, + "repentance_threshold": 0.63, + "transformation_paths": [ + "强救", + "作证", + "翻旧海图" + ] + }, + "speech_traits": [ + "粗直", + "有停顿", + "讲事实比讲情绪快" + ], + "action_traits": [ + "拽绳", + "下潜", + "把湿手套重重摔桌上" + ] + }, + { + "character_id": "qiao_su", + "display_name": "乔素", + "role": "messenger", + "destiny_contract": { + "life_theme": "记者要不要把尚未完整的真相先曝光出去" + }, + "poison_vector": { + "greed": 0.16, + "anger": 0.21, + "delusion": 0.26, + "pride": 0.54, + "doubt": 0.46 + }, + "vow_profile": { + "vows": [ + "如果他们继续删改,我就让城市先看见代价" + ], + "sacrifice_capacity": 0.49, + "truth_tolerance": 0.59 + }, + "wound_profile": { + "core_wound": "前一次提前曝光,让证人死在沉默之前", + "public_self": "我只追能被证实的部分", + "shadow_desire": "这次先救下证人,再把标题写出去", + "defense_style": "追问与抢先发布" + }, + "awakening_profile": { + "clarity": 0.45, + "reflection_capacity": 0.61, + "repentance_threshold": 0.69, + "transformation_paths": [ + "压稿", + "爆料", + "做保护性见证" + ] + }, + "speech_traits": [ + "快", + "像标题", + "善于抓最痛的那句" + ], + "action_traits": [ + "开录音", + "把截图甩到桌面", + "追着人下楼" + ] + }, + { + "character_id": "yu_xing", + "display_name": "于星", + "role": "messenger", + "destiny_contract": { + "life_theme": "少年目击者到底该被保护成失语,还是被允许说出残缺的证词" + }, + "poison_vector": { + "greed": 0.03, + "anger": 0.17, + "delusion": 0.41, + "pride": 0.14, + "doubt": 0.71 + }, + "vow_profile": { + "vows": [ + "我会把那天看见的最后一幕画下来" + ], + "sacrifice_capacity": 0.52, + "truth_tolerance": 0.43 + }, + "wound_profile": { + "core_wound": "一旦说起沉舱那晚,就会怀疑自己是不是看错了", + "public_self": "我可能记不清了", + "shadow_desire": "大人不要把我剩下的那点记忆也拿走", + "defense_style": "先退缩后突然说真话" + }, + "awakening_profile": { + "clarity": 0.31, + "reflection_capacity": 0.49, + "repentance_threshold": 0.58, + "transformation_paths": [ + "指认", + "逃离", + "递出原始画稿" + ] + }, + "speech_traits": [ + "停顿多", + "会突然换话题", + "认真时反而更直接" + ], + "action_traits": [ + "攥画本", + "后退", + "把画页撕下来塞给人" + ] + } + ], + "scene_blueprints": [ + { + "scene_id": "archive_anomaly", + "scene_function": "false_peace", + "phase_support": [ + "setup", + "early_rising", + "midpoint" + ], + "required_roles": [ + "wen_xi", + "gu_chenzhou" + ], + "beats_template": [ + "闻汐从防潮盒里抽出被替换过的空白页", + "顾沉舟逼她当场比对封存时间和调阅记录", + "她翻出被撕走标签背面的潮痕编号与旧签章", + "档案库的异常红灯亮起,说明有人比他们更早知道这一页会空" + ], + "wound_triggers": [ + "闻汐和顾沉舟在档案库第一次正面对上那段缺失的原始记忆,谁都意识到这不是普通手续错误。" + ], + "vow_tests": [ + "archive_anomaly::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "archive_anomaly::continuation", + "scene_function": "false_peace", + "location": "临港档案库", + "title": "一份不该空白的原始档案在换班前自己跳了出来", + "summary": "闻汐和顾沉舟在档案库第一次正面对上那段缺失的原始记忆,谁都意识到这不是普通手续错误。", + "tags": [ + "memory", + "harbor", + "truth" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_core_truth" + ], + "duty_allowlist": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "setup", + "early_rising", + "midpoint" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "object_state", + "information_reveal", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, + { + "scene_id": "black_market_offer", + "scene_function": "temptation", + "phase_support": [ + "setup", + "early_rising", + "midpoint", + "late_turn" + ], + "required_roles": [ + "wen_xi", + "he_mo" + ], + "beats_template": [ + "见旧熟人", + "技术诱惑", + "报价背后的条件", + "把下一次选择逼近" + ], + "wound_triggers": [ + "何默提出能替闻汐暂时补齐事故断层,但代价是把真正会伤人的一段永远挪走。" + ], + "vow_tests": [ + "black_market_offer::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "black_market_offer::continuation", + "scene_function": "temptation", + "location": "黑潮诊所", + "title": "黑市给出一个更安全也更脏的修复方案", + "summary": "何默提出能替闻汐暂时补齐事故断层,但代价是把真正会伤人的一段永远挪走。", + "tags": [ + "memory", + "debt" + ], + "agency_affordances": [ + "choice", + "memory", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "duty_allowlist": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "setup", + "early_rising", + "midpoint", + "late_turn" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "location_object", + "information_reveal", + "object_state", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, + { + "scene_id": "bridge_interrogation", + "scene_function": "truth_trial", + "phase_support": [ + "early_rising", + "midpoint", + "crisis" + ], + "required_roles": [ + "gu_chenzhou", + "song_wanqing" + ], + "beats_template": [ + "逼问账本", + "宋氏退路", + "交换条件", + "把阵营裂口挑明" + ], + "wound_triggers": [ + "顾沉舟在北栈桥追问宋晚晴,她第一次承认家族账本里有一页和沉舱名单对不上。" + ], + "vow_tests": [ + "bridge_interrogation::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "bridge_interrogation::continuation", + "scene_function": "truth_trial", + "location": "北栈桥", + "title": "继承人第一次被追问家族到底掩掉了什么", + "summary": "顾沉舟在北栈桥追问宋晚晴,她第一次承认家族账本里有一页和沉舱名单对不上。", + "tags": [ + "truth", + "power", + "harbor" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_core_truth", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "duty_allowlist": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "early_rising", + "midpoint", + "crisis" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "location_object", + "object_state", + "consequence", + "information_reveal" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, + { + "scene_id": "doctored_memory_loop", + "scene_function": "misrecognition", + "phase_support": [ + "early_rising", + "midpoint", + "crisis" + ], + "required_roles": [ + "wen_xi", + "lin_duo" + ], + "beats_template": [ + "观看删改影像", + "错认发生", + "关系被刺穿", + "留下延迟回收的误导" + ], + "wound_triggers": [ + "林朵因为接触了删改版事故影像,开始把错误的人认作姐姐最后见过的人。" + ], + "vow_tests": [ + "doctored_memory_loop::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "doctored_memory_loop::continuation", + "scene_function": "misrecognition", + "location": "潮汐公寓", + "title": "被改写过的家属记忆开始把人带向错误对象", + "summary": "林朵因为接触了删改版事故影像,开始把错误的人认作姐姐最后见过的人。", + "tags": [ + "memory", + "relationship" + ], + "agency_affordances": [ + "choice", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "duty_allowlist": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "early_rising", + "midpoint", + "crisis" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "location_object", + "consequence", + "information_reveal" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal" + ], + "dialogue_pressure": "medium", + "continuation_obligation": true + } + }, + { + "scene_id": "tower_confession", + "scene_function": "confession_window", + "phase_support": [ + "midpoint", + "crisis", + "climax", + "aftermath" + ], + "required_roles": [ + "wen_xi", + "xu_hui" + ], + "beats_template": [ + "旧案重提", + "认错来迟", + "关系债上岸", + "把下一卷代价坐实" + ], + "wound_triggers": [ + "许回在观测塔承认自己三年前撤了最后一份会救闻汐的签字,关系债第一次被正面追认。" + ], + "vow_tests": [ + "tower_confession::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "tower_confession::continuation", + "scene_function": "confession_window", + "location": "观测塔", + "title": "前搭档终于承认当年撤掉了那份关键签字", + "summary": "许回在观测塔承认自己三年前撤了最后一份会救闻汐的签字,关系债第一次被正面追认。", + "tags": [ + "debt", + "relationship", + "truth" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "duty_allowlist": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "midpoint", + "crisis", + "climax", + "aftermath" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "location_object", + "object_state", + "consequence", + "information_reveal" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, + { + "scene_id": "hearing_trade", + "scene_function": "debt_exchange", + "phase_support": [ + "midpoint", + "crisis", + "climax" + ], + "required_roles": [ + "han_jinglan", + "zhou_kai" + ], + "beats_template": [ + "会议开场", + "资源施压", + "公开难堪", + "把制度追账写进关系" + ], + "wound_triggers": [ + "韩景澜试图用搜救额度逼周凯交出沉舱区原始定位,周凯第一次把制度性代价骂到台面上。" + ], + "vow_tests": [ + "hearing_trade::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "hearing_trade::continuation", + "scene_function": "debt_exchange", + "location": "港务听证厅", + "title": "公开听证变成一场拿证词换搜救资源的交易", + "summary": "韩景澜试图用搜救额度逼周凯交出沉舱区原始定位,周凯第一次把制度性代价骂到台面上。", + "tags": [ + "public", + "debt", + "power" + ], + "agency_affordances": [ + "public", + "choice", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_choice_cost", + "tide_archive_memory_debt::series::promise_world_consequence" + ], + "duty_allowlist": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "midpoint", + "crisis", + "climax" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "location_object", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, + { + "scene_id": "submerged_return", + "scene_function": "karma_ripening", + "phase_support": [ + "crisis", + "climax", + "aftermath" + ], + "required_roles": [ + "he_mo", + "yu_xing" + ], + "beats_template": [ + "打捞箱开封,盐壳裹着半截识别牌和断裂绑带", + "于星按画稿逐笔对照残片上的指节位置与伤痕", + "何默把求救声纹和删改时间轴并排摊到台面上", + "沉舱线索迫使搜救、听证和账本三线重新并轨" + ], + "wound_triggers": [ + "于星的画稿和何默从沉舱区带回的残片对上,说明被删改的不只是名单,还有最后的求救时间。" + ], + "vow_tests": [ + "submerged_return::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "submerged_return::continuation", + "scene_function": "karma_ripening", + "location": "沉舱区", + "title": "少年目击者画里的那只手终于和沉舱区回收物对上", + "summary": "于星的画稿和何默从沉舱区带回的残片对上,说明被删改的不只是名单,还有最后的求救时间。", + "tags": [ + "memory", + "harbor", + "truth" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_core_truth", + "tide_archive_memory_debt::series::promise_world_consequence" + ], + "duty_allowlist": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "crisis", + "climax", + "aftermath" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "object_state", + "information_reveal", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal", + "object_state" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, + { + "scene_id": "public_leak", + "scene_function": "humiliation", + "phase_support": [ + "crisis", + "climax", + "aftermath" + ], + "required_roles": [ + "qiao_su", + "han_jinglan" + ], + "beats_template": [ + "直播开始", + "录音闯入", + "当场失控", + "让全城一起承担代价" + ], + "wound_triggers": [ + "乔素在直播中放出一段删改前录音,韩景澜第一次在公开场合失去对叙事的控制。" + ], + "vow_tests": [ + "public_leak::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "public_leak::continuation", + "scene_function": "humiliation", + "location": "港务听证厅", + "title": "一段被压下去的录音在听证直播里被当场放出", + "summary": "乔素在直播中放出一段删改前录音,韩景澜第一次在公开场合失去对叙事的控制。", + "tags": [ + "public", + "truth", + "power" + ], + "agency_affordances": [ + "public", + "choice", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_world_consequence" + ], + "duty_allowlist": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "crisis", + "climax", + "aftermath" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "location_object", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, + { + "scene_id": "caisson_vow", + "scene_function": "vow_payment", + "phase_support": [ + "climax", + "aftermath" + ], + "required_roles": [ + "song_wanqing", + "zhou_kai" + ], + "beats_template": [ + "条件摊牌", + "下潜选择", + "誓言兑付", + "把结局形态推近" + ], + "wound_triggers": [ + "宋晚晴被迫决定是否亲自开放家族封存区协助下潜,她第一次需要拿自己的继承资格替别人换生路。" + ], + "vow_tests": [ + "caisson_vow::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "caisson_vow::continuation", + "scene_function": "vow_payment", + "location": "沉舱区", + "title": "有人必须亲自下潜,才能兑现那句“先把活人带回来”", + "summary": "宋晚晴被迫决定是否亲自开放家族封存区协助下潜,她第一次需要拿自己的继承资格替别人换生路。", + "tags": [ + "survival", + "debt", + "harbor" + ], + "agency_affordances": [ + "choice", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_choice_cost", + "tide_archive_memory_debt::series::promise_world_consequence" + ], + "duty_allowlist": [ + "resolve_promise", + "deliver_climax" + ], + "phase_allowlist": [ + "climax", + "aftermath" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "location_object", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion" + ], + "dialogue_pressure": "high", + "continuation_obligation": true + } + }, + { + "scene_id": "partner_break", + "scene_function": "mask_crack", + "phase_support": [ + "midpoint", + "crisis", + "aftermath" + ], + "required_roles": [ + "wen_xi", + "qiao_su" + ], + "beats_template": [ + "发稿先行", + "保护失败", + "关系裂口", + "把后面十章的延迟后果埋下" + ], + "wound_triggers": [ + "乔素提前发稿逼得闻汐失去一个证人保护窗口,两人必须重新定义彼此到底是在救人还是在抢叙事。" + ], + "vow_tests": [ + "partner_break::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "partner_break::continuation", + "scene_function": "mask_crack", + "location": "潮汐公寓", + "title": "同盟关系因为一次抢先发布在夜里当场裂开", + "summary": "乔素提前发稿逼得闻汐失去一个证人保护窗口,两人必须重新定义彼此到底是在救人还是在抢叙事。", + "tags": [ + "relationship", + "public" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "duty_allowlist": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "midpoint", + "crisis", + "aftermath" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "location_object", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion" + ], + "dialogue_pressure": "medium", + "continuation_obligation": true + } + }, + { + "scene_id": "rescue_split", + "scene_function": "false_peace", + "phase_support": [ + "crisis", + "aftermath" + ], + "required_roles": [ + "lin_duo", + "zhou_kai" + ], + "beats_template": [ + "人被带回", + "安静得不对", + "分歧成形", + "把下一次公开代价推近" + ], + "wound_triggers": [ + "一名边缘证人被暂时救上来后,林朵和周凯在是否立刻公开身份上产生致命分歧。" + ], + "vow_tests": [ + "rescue_split::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "rescue_split::continuation", + "scene_function": "false_peace", + "location": "封存码头", + "title": "短暂的搜救成功反而把更大的分裂压到台前", + "summary": "一名边缘证人被暂时救上来后,林朵和周凯在是否立刻公开身份上产生致命分歧。", + "tags": [ + "survival", + "relationship" + ], + "agency_affordances": [ + "choice", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_world_consequence" + ], + "duty_allowlist": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "crisis", + "aftermath" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "location_object", + "information_reveal", + "object_state", + "consequence" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion", + "ambient_signal" + ], + "dialogue_pressure": "medium", + "continuation_obligation": true + } + }, + { + "scene_id": "witness_handoff", + "scene_function": "confession_window", + "phase_support": [ + "climax", + "aftermath" + ], + "required_roles": [ + "wen_xi", + "yu_xing" + ], + "beats_template": [ + "画稿递出", + "真正难选的对象显形", + "关系压力回到闻汐身上", + "为96-100章收束保留最后压力" + ], + "wound_triggers": [ + "于星把最后一页原始画稿递给闻汐,逼她决定是先保住孩子的残余记忆,还是先把全城需要的证据送上岸。" + ], + "vow_tests": [ + "witness_handoff::vow_test" + ], + "seed_templates": [], + "continuation_blueprints": [ + { + "blueprint_id": "witness_handoff::continuation", + "scene_function": "confession_window", + "location": "临港档案库", + "title": "少年终于递出那一页一直没敢交出去的原始画稿", + "summary": "于星把最后一页原始画稿递给闻汐,逼她决定是先保住孩子的残余记忆,还是先把全城需要的证据送上岸。", + "tags": [ + "truth", + "relationship", + "memory" + ], + "agency_affordances": [ + "truth", + "choice", + "continue_story" + ], + "promises_close": [ + "tide_archive_memory_debt::series::promise_core_truth", + "tide_archive_memory_debt::series::promise_choice_cost", + "tide_archive_memory_debt::series::promise_world_consequence" + ], + "duty_allowlist": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ], + "phase_allowlist": [ + "climax", + "aftermath" + ], + "tension_delta": 0.12 + } + ], + "ending_gate": {}, + "quality_contract": { + "variation_axes": [ + "voice", + "movement", + "location_object", + "consequence", + "information_reveal" + ], + "detail_anchor_types": [ + "object", + "sound", + "body_motion" + ], + "dialogue_pressure": "medium", + "continuation_obligation": true + } + } + ], + "style_pack": { + "mode": "novel_lush", + "pov": "limited_third", + "dialogue_density": "medium_high" + }, + "narrative_style_pack": { + "style_pack_id": "tide_archive_style", + "tonal_lexicon": [ + "潮痕", + "封存", + "听证", + "沉舱", + "追账", + "删改" + ], + "thematic_axis_labels": { + "near_future_harbor_mystery": "记忆交易与港口秩序", + "ensemble_drama": "关系债与站队", + "conspiracy": "删改、封存与公开代价", + "memory_trade": "真相与可承受之重" + }, + "hook_templates": [ + "这一章停在这里时,真正追上来的不是答案,而是下一次谁会先把那份原始记忆递上桌。", + "局势像潮水退了一步,可更难的那层账正好露出来。" + ], + "dialogue": { + "policy_id": "tide_archive_dialogue", + "require_turn_taking": true, + "require_counter_reaction": true, + "min_turns": 3, + "max_turns": 5, + "turn_pattern": [ + "speaker", + "reaction", + "reply", + "echo" + ], + "minimum_exchanges": 2, + "voice_profiles": { + "wen_xi": { + "profile_id": "wen_xi", + "cadence": "measured", + "directness": 0.56, + "bluntness": 0.18, + "restraint": 0.88, + "social_rank_awareness": 0.38, + "opening_style": [ + "流程可以晚一点,真相不能再被人替我写。" + ], + "pressure_style": [ + "流程可以晚一点,真相不能再被人替我写,现在就得给我一个能承担的版本。" + ], + "pivot_style": [ + "流程可以晚一点,真相不能再被人替我写,再拖下去只会换个地方继续裂。" + ], + "aftermath_style": [ + "流程可以晚一点,真相不能再被人替我写,后面的代价我会记着是谁先推来的。" + ], + "echo_style": [ + "流程可以晚一点,真相不能再被人替我写,下次见面时它还会追上来。" + ], + "signature_replies": [ + "流程可以晚一点,真相不能再被人替我写。" + ] + }, + "gu_chenzhou": { + "profile_id": "gu_chenzhou", + "cadence": "tight", + "directness": 0.86, + "bluntness": 0.88, + "restraint": 0.19, + "social_rank_awareness": 0.31, + "opening_style": [ + "你若还想改口,就先告诉我你要谁替你付账。" + ], + "pressure_style": [ + "你若还想改口,就先告诉我你要谁替你付账,现在就得给我一个能承担的版本。" + ], + "pivot_style": [ + "你若还想改口,就先告诉我你要谁替你付账,再拖下去只会换个地方继续裂。" + ], + "aftermath_style": [ + "你若还想改口,就先告诉我你要谁替你付账,后面的代价我会记着是谁先推来的。" + ], + "echo_style": [ + "你若还想改口,就先告诉我你要谁替你付账,下次见面时它还会追上来。" + ], + "signature_replies": [ + "你若还想改口,就先告诉我你要谁替你付账。" + ] + }, + "xu_hui": { + "profile_id": "xu_hui", + "cadence": "steady", + "directness": 0.48, + "bluntness": 0.26, + "restraint": 0.74, + "social_rank_awareness": 0.44, + "opening_style": [ + "我知道这句来得晚,但这次我不把它拖到下一轮。" + ], + "pressure_style": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,现在就得给我一个能承担的版本。" + ], + "pivot_style": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,再拖下去只会换个地方继续裂。" + ], + "aftermath_style": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,后面的代价我会记着是谁先推来的。" + ], + "echo_style": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,下次见面时它还会追上来。" + ], + "signature_replies": [ + "我知道这句来得晚,但这次我不把它拖到下一轮。" + ] + }, + "song_wanqing": { + "profile_id": "song_wanqing", + "cadence": "polished", + "directness": 0.72, + "bluntness": 0.61, + "restraint": 0.33, + "social_rank_awareness": 0.85, + "opening_style": [ + "合作也好,背叛也好,先把价码摆到桌面上。" + ], + "pressure_style": [ + "合作也好,背叛也好,先把价码摆到桌面上,现在就得给我一个能承担的版本。" + ], + "pivot_style": [ + "合作也好,背叛也好,先把价码摆到桌面上,再拖下去只会换个地方继续裂。" + ], + "aftermath_style": [ + "合作也好,背叛也好,先把价码摆到桌面上,后面的代价我会记着是谁先推来的。" + ], + "echo_style": [ + "合作也好,背叛也好,先把价码摆到桌面上,下次见面时它还会追上来。" + ], + "signature_replies": [ + "合作也好,背叛也好,先把价码摆到桌面上。" + ] + } + }, + "response_profiles": { + "wen_xi": { + "cadence_id": "wen_xi", + "reaction_tempo": "measured", + "reaction_lines": { + "entry": [ + "闻汐先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "闻汐把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,闻汐反而比刚才更稳。" + ], + "aftermath": [ + "闻汐没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "流程可以晚一点,真相不能再被人替我写。" + ], + "pressure": [ + "流程可以晚一点,真相不能再被人替我写,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "流程可以晚一点,真相不能再被人替我写,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "流程可以晚一点,真相不能再被人替我写,这句话先落着,账不会自己散。" + ], + "echo": [ + "流程可以晚一点,真相不能再被人替我写,它迟早会在下一个章节追上来。" + ] + } + }, + "gu_chenzhou": { + "cadence_id": "gu_chenzhou", + "reaction_tempo": "tight", + "reaction_lines": { + "entry": [ + "顾沉舟先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "顾沉舟把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,顾沉舟反而比刚才更稳。" + ], + "aftermath": [ + "顾沉舟没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "你若还想改口,就先告诉我你要谁替你付账。" + ], + "pressure": [ + "你若还想改口,就先告诉我你要谁替你付账,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "你若还想改口,就先告诉我你要谁替你付账,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "你若还想改口,就先告诉我你要谁替你付账,这句话先落着,账不会自己散。" + ], + "echo": [ + "你若还想改口,就先告诉我你要谁替你付账,它迟早会在下一个章节追上来。" + ] + } + }, + "xu_hui": { + "cadence_id": "xu_hui", + "reaction_tempo": "steady", + "reaction_lines": { + "entry": [ + "许回先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "许回把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,许回反而比刚才更稳。" + ], + "aftermath": [ + "许回没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "我知道这句来得晚,但这次我不把它拖到下一轮。" + ], + "pressure": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,这句话先落着,账不会自己散。" + ], + "echo": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,它迟早会在下一个章节追上来。" + ] + } + }, + "song_wanqing": { + "cadence_id": "song_wanqing", + "reaction_tempo": "polished", + "reaction_lines": { + "entry": [ + "宋晚晴先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "宋晚晴把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,宋晚晴反而比刚才更稳。" + ], + "aftermath": [ + "宋晚晴没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "合作也好,背叛也好,先把价码摆到桌面上。" + ], + "pressure": [ + "合作也好,背叛也好,先把价码摆到桌面上,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "合作也好,背叛也好,先把价码摆到桌面上,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "合作也好,背叛也好,先把价码摆到桌面上,这句话先落着,账不会自己散。" + ], + "echo": [ + "合作也好,背叛也好,先把价码摆到桌面上,它迟早会在下一个章节追上来。" + ] + } + } + }, + "pressure_styles": { + "wen_xi": { + "style_id": "wen_xi", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "gu_chenzhou": { + "style_id": "gu_chenzhou", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "xu_hui": { + "style_id": "xu_hui", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "song_wanqing": { + "style_id": "song_wanqing", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + } + } + }, + "emotion_actions": { + "policy_id": "tide_archive_emotion_action", + "action_map": { + "false_peace": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "temptation": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "truth_trial": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "misrecognition": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "confession_window": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "debt_exchange": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "karma_ripening": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "humiliation": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "vow_payment": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "mask_crack": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + } + } + }, + "sensory_grounding": { + "policy_id": "tide_archive_sensory", + "location_slots": { + "临港档案库": { + "atmosphere": [ + "恒温库的冷气带着盐味,像有人把海风也归档进了这一层安静里。" + ], + "detail": [ + "旧磁带盒、潮痕标签和防潮手套摩擦出的细响,把每个人的迟疑都照得很实。" + ], + "repeat_detail": [ + "越往里走,灯带下那层反光越像被删改过的记忆在自己回潮。" + ] + }, + "北栈桥": { + "atmosphere": [ + "海风把栈桥吹得空旷,任何一句谎话在这里都会显得太轻。" + ], + "detail": [ + "锈钉、湿木板和桥下回弹的浪声,让站在边上的人连呼吸都带着停顿。" + ], + "repeat_detail": [ + "每次回到这里,脚下那点晃动都像提醒他们事故从没结束。" + ] + }, + "封存码头": { + "atmosphere": [ + "封存区安静得过分,像所有集装箱都在替某份旧账闭嘴。" + ], + "detail": [ + "铅封编号、盐斑铁门和被水浸过的货单边角,把隐瞒变得一眼可见。" + ], + "repeat_detail": [ + "越靠近沉舱名单,空气里的铁锈味越像有人在追着认账。" + ] + }, + "潮汐公寓": { + "atmosphere": [ + "走廊里总有潮湿电流的嗡响,像整栋楼都在替住户保守秘密。" + ], + "detail": [ + "坏掉一半的门铃、晾衣绳上的盐渍和狭窄窗缝里的海光,把关系压得很近。" + ], + "repeat_detail": [ + "夜里再回来时,楼道灯忽明忽暗,像谁也不敢把这层生活照全。" + ] + }, + "黑潮诊所": { + "atmosphere": [ + "诊所里消毒水和旧电路的味道混在一起,让人分不清这里是在救人还是修补证据。" + ], + "detail": [ + "记忆芯片盒、遮光帘和手术灯打出的冷白边缘,让每个细小动作都像可被追责的证词。" + ], + "repeat_detail": [ + "每次机器重新预热,墙上那圈蓝光都像把谁的隐瞒先照出轮廓。" + ] + }, + "港务听证厅": { + "atmosphere": [ + "听证厅太亮,亮得任何人的退路都像会先被投到大屏上。" + ], + "detail": [ + "翻页声、话筒底噪和桌牌边缘的反光,把一场公开代价拆成很多难堪的小瞬间。" + ], + "repeat_detail": [ + "越到后段,空调风和观众席的小动作越像在替这场审判记笔记。" + ] + }, + "沉舱区": { + "atmosphere": [ + "下潜平台上的风始终带着金属湿味,像海水还没决定要把什么吐出来。" + ], + "detail": [ + "氧气表、系绳扣和探照灯扫过水面的那一下,让每个决定都像先欠了一条命。" + ], + "repeat_detail": [ + "每次绳索重新绷紧,沉舱区都会把旧求救声原样推回来半秒。" + ] + }, + "观测塔": { + "atmosphere": [ + "高处的玻璃把港口切成很多冷静的平面,像所有人都被迫从上方看自己的选择。" + ], + "detail": [ + "风压、望远镜转轴和远处船灯在玻璃上的拖影,让一句话的后果显得更长。" + ], + "repeat_detail": [ + "站久了以后,连下方船鸣的间隔都像在提醒他们该不该把真相放出去。" + ] + } + }, + "generic_slots": { + "atmosphere": [ + "这座港城从不真正安静,像每一层秩序后面都压着一段还没认下的旧账。" + ], + "detail": [ + "潮气、金属和屏幕冷光反复提醒人,这里没有一句话能白白落地。" + ], + "repeat_detail": [ + "等沉默拖长以后,最先追上来的往往不是结论,而是那一点被拖延太久的细节。" + ] + } + }, + "scene_realization": { + "contract_id": "tide_archive_scene_realization", + "dialogue_policy_id": "tide_archive_dialogue", + "default_voice_profile_id": "wen_xi", + "default_cadence_id": "wen_xi", + "default_pressure_style_id": "wen_xi", + "default_emotion_action_policy_id": "default", + "default_sensory_policy_id": "default", + "narrative_style_pack_id": "tide_archive_style", + "scene_openings": { + "false_peace": [ + "表面上只是一次常规归档,可真正让人不敢抬头的是谁都知道这里少了一段原始记忆。" + ], + "truth_trial": [ + "逼问开始时,场面反而更静,像每个人都在等那句最难认的话自己浮上来。" + ], + "mask_crack": [ + "裂开的先不是情绪,而是每个人原本拿来维持体面的那层程序。" + ], + "confession_window": [ + "终于有人把真话推到桌面上,可桌面本身也在问:谁来替它付账。" + ], + "debt_exchange": [ + "旧账一旦开始结算,关系里就很难再有完全干净的退路。" + ], + "karma_ripening": [ + "前面被封存的东西开始回潮,城市先替它发出声音。" + ], + "humiliation": [ + "最难堪的代价被放到了公开场面,谁都不能只靠解释活过去。" + ], + "vow_payment": [ + "誓言要兑现时,先被推出来的往往不是真相,而是真正该承担的人。" + ], + "misrecognition": [ + "错认不是意外,而是被改写过的记忆终于开始收利息。" + ], + "temptation": [ + "每一种更安全的选项,都带着更长的一笔后账。" + ] + }, + "scene_hooks": { + "false_peace": [ + "这层平静撑不到下一次换班,真正会追上来的,是那份谁都不敢先开的原始记录。" + ], + "truth_trial": [ + "话先停在这里,可下一章最难的是谁愿意把证词真正签出去。" + ], + "mask_crack": [ + "装出来的稳已经裂了,下一步只能更硬地选边。" + ], + "confession_window": [ + "真话已经说出一半,剩下那一半会在谁手里变成新的代价。" + ], + "debt_exchange": [ + "旧账不可能就地结清,它只会换一个关系继续追。" + ] + }, + "scene_pressures": {} + }, + "goal_labels": { + "truth_first": "真相优先", + "relationship_first": "关系优先", + "survival_first": "生存优先", + "power_first": "权力优先" + }, + "tag_labels": { + "memory": "记忆", + "debt": "关系债", + "public": "公开代价", + "harbor": "海港" + } + }, + "risk_policy": { + "shareable": true, + "requires_manual_review": false + }, + "series_plan": { + "series_id": "tide_archive_memory_debt::series", + "title": "潮汐档案", + "total_volume_target": 5, + "total_chapter_target": 100, + "target_word_count": 200000, + "theme_statement": "当记忆可以被交易,人靠什么承担承诺", + "series_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::promise_core_truth", + "label": "潮汐档案 的核心真相迟早要被真正认下", + "holders": [ + "lead", + "counterpart" + ], + "stakes": "high", + "due_by_chapter": 0, + "source_level": "series", + "description": "围绕“当记忆可以被交易,人靠什么承担承诺”的核心真相,必须在长线中被真正承担。" + }, + { + "promise_id": "tide_archive_memory_debt::series::promise_choice_cost", + "label": "每一次选择都要先有代价落到关系里", + "holders": [ + "lead", + "counterpart" + ], + "stakes": "high", + "due_by_chapter": 0, + "source_level": "series", + "description": "长线里的重大推进必须伴随关系、局势或代价的重新分配。" + }, + { + "promise_id": "tide_archive_memory_debt::series::promise_world_consequence", + "label": "世界层后果不会自动消失", + "holders": [ + "lead" + ], + "stakes": "medium", + "due_by_chapter": 0, + "source_level": "series", + "description": "外部异变、秩序反噬或系统代价必须持续回到主线中追账。" + } + ] + }, + "volume_plans": [ + { + "volume_id": "tide_archive_memory_debt::series::volume_1", + "order": 1, + "title": "潮门初裂", + "goal": "建立记忆交易规则、关系债和第一次错误选择。", + "target_chapters": 20, + "climax_definition": "卷高潮必须在听证/档案/事故三线里打出第一次公开代价。", + "end_state": "留下更大的事故口径分歧与下一卷站队压力。", + "volume_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_1::promise_major_shift", + "label": "潮门初裂必须改变一层稳定关系或稳定叙事。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "high", + "due_by_chapter": 20, + "source_level": "volume", + "description": "建立记忆交易规则、关系债和第一次错误选择。" + }, + { + "promise_id": "tide_archive_memory_debt::series::volume_1::promise_cost_visible", + "label": "潮门初裂里的代价必须对外显形。", + "holders": [ + "han_jinglan", + "song_wanqing", + "qiao_su" + ], + "stakes": "medium", + "due_by_chapter": 20, + "source_level": "volume", + "description": "留下更大的事故口径分歧与下一卷站队压力。" + } + ] + }, + { + "volume_id": "tide_archive_memory_debt::series::volume_2", + "order": 2, + "title": "账本上岸", + "goal": "调查升级、阵营分裂、第一次公开代价真正落到关系里。", + "target_chapters": 20, + "climax_definition": "卷高潮必须让一个阵营在公开场面失去原有叙事权。", + "end_state": "把关系债和制度代价一起推入中段。", + "volume_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_2::promise_major_shift", + "label": "账本上岸必须改变一层稳定关系或稳定叙事。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "high", + "due_by_chapter": 40, + "source_level": "volume", + "description": "调查升级、阵营分裂、第一次公开代价真正落到关系里。" + }, + { + "promise_id": "tide_archive_memory_debt::series::volume_2::promise_cost_visible", + "label": "账本上岸里的代价必须对外显形。", + "holders": [ + "han_jinglan", + "song_wanqing", + "qiao_su" + ], + "stakes": "medium", + "due_by_chapter": 40, + "source_level": "volume", + "description": "把关系债和制度代价一起推入中段。" + } + ] + }, + { + "volume_id": "tide_archive_memory_debt::series::volume_3", + "order": 3, + "title": "中潮迷航", + "goal": "在中段疲劳带里验证重复、解释增多与角色漂移压力。", + "target_chapters": 20, + "climax_definition": "卷高潮必须让中段回圈变成具体损失,而不是解释。", + "end_state": "以未结清的错认和延迟后果推进卷四。", + "volume_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_3::promise_major_shift", + "label": "中潮迷航必须改变一层稳定关系或稳定叙事。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "high", + "due_by_chapter": 60, + "source_level": "volume", + "description": "在中段疲劳带里验证重复、解释增多与角色漂移压力。" + }, + { + "promise_id": "tide_archive_memory_debt::series::volume_3::promise_cost_visible", + "label": "中潮迷航里的代价必须对外显形。", + "holders": [ + "han_jinglan", + "song_wanqing", + "qiao_su" + ], + "stakes": "medium", + "due_by_chapter": 60, + "source_level": "volume", + "description": "以未结清的错认和延迟后果推进卷四。" + } + ] + }, + { + "volume_id": "tide_archive_memory_debt::series::volume_4", + "order": 4, + "title": "旧债回潮", + "goal": "旧债集中结算,路线开始显著分化,延迟回收进入高峰。", + "target_chapters": 20, + "climax_definition": "卷高潮必须把至少两条路线的价值冲突拉到正面。", + "end_state": "让结局前的选择不再可能同时保全所有关系。", + "volume_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_4::promise_major_shift", + "label": "旧债回潮必须改变一层稳定关系或稳定叙事。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "high", + "due_by_chapter": 80, + "source_level": "volume", + "description": "旧债集中结算,路线开始显著分化,延迟回收进入高峰。" + }, + { + "promise_id": "tide_archive_memory_debt::series::volume_4::promise_cost_visible", + "label": "旧债回潮里的代价必须对外显形。", + "holders": [ + "han_jinglan", + "song_wanqing", + "qiao_su" + ], + "stakes": "medium", + "due_by_chapter": 80, + "source_level": "volume", + "description": "让结局前的选择不再可能同时保全所有关系。" + } + ] + }, + { + "volume_id": "tide_archive_memory_debt::series::volume_5", + "order": 5, + "title": "终港对证", + "goal": "85-95 章持续制造 continuation pressure,96-100 收束且禁止过早终结。", + "target_chapters": 20, + "climax_definition": "卷高潮必须把公开揭露/私下保全/牺牲封口/带罪共存四类结局都推到可兑现状态。", + "end_state": "在付出明确代价后完成收束,不用系统说明代替人物承担。", + "volume_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_5::promise_major_shift", + "label": "终港对证必须改变一层稳定关系或稳定叙事。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "high", + "due_by_chapter": 100, + "source_level": "volume", + "description": "85-95 章持续制造 continuation pressure,96-100 收束且禁止过早终结。" + }, + { + "promise_id": "tide_archive_memory_debt::series::volume_5::promise_cost_visible", + "label": "终港对证里的代价必须对外显形。", + "holders": [ + "han_jinglan", + "song_wanqing", + "qiao_su" + ], + "stakes": "medium", + "due_by_chapter": 100, + "source_level": "volume", + "description": "在付出明确代价后完成收束,不用系统说明代替人物承担。" + } + ] + } + ], + "arc_plans": [ + { + "arc_id": "tide_archive_memory_debt::series::volume_1::arc_1", + "volume_id": "tide_archive_memory_debt::series::volume_1", + "order": 1, + "title": "档案上岸", + "goal": "把缺失记忆和事故旧账真正拉回主线。", + "conflict": "闻汐与顾沉舟必须在合作与互疑之间完成第一次站位。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_1::promise_major_shift", + "tide_archive_memory_debt::series::volume_1::arc_1::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened", + "next_chapter_hook_intensified" + ], + "target_chapters": 6, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_1::arc_1::promise_turn", + "label": "档案上岸必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 12, + "source_level": "arc", + "description": "闻汐与顾沉舟必须在合作与互疑之间完成第一次站位。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_1::task_1", + "objective": "第1章围绕“档案上岸”执行 advance_plot:把缺失记忆和事故旧账真正拉回主线。", + "duty_type": "advance_plot", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::promise_main", + "tide_archive_memory_debt::series::promise_core_truth" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_1::task_2", + "objective": "第2章围绕“档案上岸”执行 advance_relationship:把缺失记忆和事故旧账真正拉回主线。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_1::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_1::task_3", + "objective": "第3章围绕“档案上岸”执行 expand_world:把缺失记忆和事故旧账真正拉回主线。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_1::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_1::task_4", + "objective": "第4章围绕“档案上岸”执行 resolve_promise:把缺失记忆和事故旧账真正拉回主线。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_1::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_1::task_5", + "objective": "第5章围绕“档案上岸”执行 pace_breath:把缺失记忆和事故旧账真正拉回主线。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_1::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_1::task_6", + "objective": "第6章围绕“档案上岸”执行 resolve_promise:把缺失记忆和事故旧账真正拉回主线。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_1::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_1::arc_2", + "volume_id": "tide_archive_memory_debt::series::volume_1", + "order": 2, + "title": "伪证开箱", + "goal": "让删改版证据带来第一轮错误推进。", + "conflict": "错认和隐瞒必须在 3-10 章后回收。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_1::promise_major_shift", + "tide_archive_memory_debt::series::volume_1::arc_2::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened", + "next_chapter_hook_intensified" + ], + "target_chapters": 6, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_1::arc_2::promise_turn", + "label": "伪证开箱必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 18, + "source_level": "arc", + "description": "错认和隐瞒必须在 3-10 章后回收。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_2::task_1", + "objective": "第7章围绕“伪证开箱”执行 advance_relationship:让删改版证据带来第一轮错误推进。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_1::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_2::task_2", + "objective": "第8章围绕“伪证开箱”执行 expand_world:让删改版证据带来第一轮错误推进。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_1::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_2::task_3", + "objective": "第9章围绕“伪证开箱”执行 resolve_promise:让删改版证据带来第一轮错误推进。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_1::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_2::task_4", + "objective": "第10章围绕“伪证开箱”执行 pace_breath:让删改版证据带来第一轮错误推进。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_2::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | delayed_payoff_window=3-10 | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_2::task_5", + "objective": "第11章围绕“伪证开箱”执行 deliver_climax:让删改版证据带来第一轮错误推进。", + "duty_type": "deliver_climax", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "close_arc_loop", + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_1::promise_relationship", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 5 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_2::task_6", + "objective": "第12章围绕“伪证开箱”执行 resolve_promise:让删改版证据带来第一轮错误推进。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_1::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_1::arc_3", + "volume_id": "tide_archive_memory_debt::series::volume_1", + "order": 3, + "title": "听证失控", + "goal": "第一次把港务系统的公开代价推到台前。", + "conflict": "卷一高潮必须不是解释,而是公开后果。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_1::promise_major_shift", + "tide_archive_memory_debt::series::volume_1::arc_3::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 8, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_1::arc_3::promise_turn", + "label": "听证失控必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 26, + "source_level": "arc", + "description": "卷一高潮必须不是解释,而是公开后果。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_3::task_1", + "objective": "第13章围绕“听证失控”执行 expand_world:第一次把港务系统的公开代价推到台前。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_1::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_3::task_2", + "objective": "第14章围绕“听证失控”执行 resolve_promise:第一次把港务系统的公开代价推到台前。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_1::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_3::task_3", + "objective": "第15章围绕“听证失控”执行 pace_breath:第一次把港务系统的公开代价推到台前。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_3::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | introduce_new_cost | interactive_relationship_steer", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_3::task_4", + "objective": "第16章围绕“听证失控”执行 deliver_climax:第一次把港务系统的公开代价推到台前。", + "duty_type": "deliver_climax", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "close_arc_loop", + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_1::promise_relationship", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 5 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_3::task_5", + "objective": "第17章围绕“听证失控”执行 advance_plot:第一次把港务系统的公开代价推到台前。", + "duty_type": "advance_plot", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::promise_main", + "tide_archive_memory_debt::series::promise_core_truth" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_3::task_6", + "objective": "第18章围绕“听证失控”执行 advance_relationship:第一次把港务系统的公开代价推到台前。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_1::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_3::task_7", + "objective": "第19章围绕“听证失控”执行 expand_world:第一次把港务系统的公开代价推到台前。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_1::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_1::arc_3::task_8", + "objective": "第20章围绕“听证失控”执行 resolve_promise:第一次把港务系统的公开代价推到台前。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_1::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_1::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | delayed_payoff_window=3-10 | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_2::arc_1", + "volume_id": "tide_archive_memory_debt::series::volume_2", + "order": 1, + "title": "黑市换忆", + "goal": "把“更安全的假真相”做成真正诱惑。", + "conflict": "关系优先与真相优先开始正面冲突。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_2::promise_major_shift", + "tide_archive_memory_debt::series::volume_2::arc_1::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 6, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_2::arc_1::promise_turn", + "label": "黑市换忆必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 32, + "source_level": "arc", + "description": "关系优先与真相优先开始正面冲突。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_1::task_1", + "objective": "第21章围绕“黑市换忆”执行 advance_plot:把“更安全的假真相”做成真正诱惑。", + "duty_type": "advance_plot", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::promise_main", + "tide_archive_memory_debt::series::promise_core_truth" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_1::task_2", + "objective": "第22章围绕“黑市换忆”执行 advance_relationship:把“更安全的假真相”做成真正诱惑。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_2::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_1::task_3", + "objective": "第23章围绕“黑市换忆”执行 expand_world:把“更安全的假真相”做成真正诱惑。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_2::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_1::task_4", + "objective": "第24章围绕“黑市换忆”执行 resolve_promise:把“更安全的假真相”做成真正诱惑。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_2::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_1::task_5", + "objective": "第25章围绕“黑市换忆”执行 pace_breath:把“更安全的假真相”做成真正诱惑。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_1::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_1::task_6", + "objective": "第26章围绕“黑市换忆”执行 resolve_promise:把“更安全的假真相”做成真正诱惑。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_2::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_2::arc_2", + "volume_id": "tide_archive_memory_debt::series::volume_2", + "order": 2, + "title": "阵营翻潮", + "goal": "让宋氏、港务局、搜救队与媒体同时分裂。", + "conflict": "选择不再只是私人,而是会改写一整个阵营的成本分配。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_2::promise_major_shift", + "tide_archive_memory_debt::series::volume_2::arc_2::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 6, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_2::arc_2::promise_turn", + "label": "阵营翻潮必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 38, + "source_level": "arc", + "description": "选择不再只是私人,而是会改写一整个阵营的成本分配。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_2::task_1", + "objective": "第27章围绕“阵营翻潮”执行 advance_relationship:让宋氏、港务局、搜救队与媒体同时分裂。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_2::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_2::task_2", + "objective": "第28章围绕“阵营翻潮”执行 expand_world:让宋氏、港务局、搜救队与媒体同时分裂。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_2::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_2::task_3", + "objective": "第29章围绕“阵营翻潮”执行 resolve_promise:让宋氏、港务局、搜救队与媒体同时分裂。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_2::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_2::task_4", + "objective": "第30章围绕“阵营翻潮”执行 pace_breath:让宋氏、港务局、搜救队与媒体同时分裂。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_2::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | delayed_payoff_window=3-10 | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_2::task_5", + "objective": "第31章围绕“阵营翻潮”执行 deliver_climax:让宋氏、港务局、搜救队与媒体同时分裂。", + "duty_type": "deliver_climax", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "close_arc_loop", + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_2::promise_relationship", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 5 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_2::task_6", + "objective": "第32章围绕“阵营翻潮”执行 resolve_promise:让宋氏、港务局、搜救队与媒体同时分裂。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_2::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_2::arc_3", + "volume_id": "tide_archive_memory_debt::series::volume_2", + "order": 3, + "title": "公开代价", + "goal": "把第一次公开曝光的回潮代价压到核心关系上。", + "conflict": "角色必须因为公开代价重新定义彼此的债。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_2::promise_major_shift", + "tide_archive_memory_debt::series::volume_2::arc_3::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 8, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_2::arc_3::promise_turn", + "label": "公开代价必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 46, + "source_level": "arc", + "description": "角色必须因为公开代价重新定义彼此的债。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_3::task_1", + "objective": "第33章围绕“公开代价”执行 expand_world:把第一次公开曝光的回潮代价压到核心关系上。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_2::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | interactive_arc_goal_shift", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_3::task_2", + "objective": "第34章围绕“公开代价”执行 resolve_promise:把第一次公开曝光的回潮代价压到核心关系上。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_2::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_3::task_3", + "objective": "第35章围绕“公开代价”执行 pace_breath:把第一次公开曝光的回潮代价压到核心关系上。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_3::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_3::task_4", + "objective": "第36章围绕“公开代价”执行 deliver_climax:把第一次公开曝光的回潮代价压到核心关系上。", + "duty_type": "deliver_climax", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "close_arc_loop", + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_2::promise_relationship", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 5 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_3::task_5", + "objective": "第37章围绕“公开代价”执行 advance_plot:把第一次公开曝光的回潮代价压到核心关系上。", + "duty_type": "advance_plot", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::promise_main", + "tide_archive_memory_debt::series::promise_core_truth" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_3::task_6", + "objective": "第38章围绕“公开代价”执行 advance_relationship:把第一次公开曝光的回潮代价压到核心关系上。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_2::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_3::task_7", + "objective": "第39章围绕“公开代价”执行 expand_world:把第一次公开曝光的回潮代价压到核心关系上。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_2::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_2::arc_3::task_8", + "objective": "第40章围绕“公开代价”执行 resolve_promise:把第一次公开曝光的回潮代价压到核心关系上。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_2::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_2::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | delayed_payoff_window=3-10 | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_3::arc_1", + "volume_id": "tide_archive_memory_debt::series::volume_3", + "order": 1, + "title": "证词回圈", + "goal": "故意进入中段回圈压力,观察系统是否能继续制造新信息。", + "conflict": "不能靠重复对峙维持长度,必须每五章引入新的关系债或规则成本。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_3::promise_major_shift", + "tide_archive_memory_debt::series::volume_3::arc_1::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 6, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_3::arc_1::promise_turn", + "label": "证词回圈必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 52, + "source_level": "arc", + "description": "不能靠重复对峙维持长度,必须每五章引入新的关系债或规则成本。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_1::task_1", + "objective": "第41章围绕“证词回圈”执行 advance_plot:故意进入中段回圈压力,观察系统是否能继续制造新信息。", + "duty_type": "advance_plot", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::promise_main", + "tide_archive_memory_debt::series::promise_core_truth" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_1::task_2", + "objective": "第42章围绕“证词回圈”执行 advance_relationship:故意进入中段回圈压力,观察系统是否能继续制造新信息。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_3::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_1::task_3", + "objective": "第43章围绕“证词回圈”执行 expand_world:故意进入中段回圈压力,观察系统是否能继续制造新信息。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_3::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_1::task_4", + "objective": "第44章围绕“证词回圈”执行 resolve_promise:故意进入中段回圈压力,观察系统是否能继续制造新信息。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_3::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_1::task_5", + "objective": "第45章围绕“证词回圈”执行 pace_breath:故意进入中段回圈压力,观察系统是否能继续制造新信息。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_1::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_1::task_6", + "objective": "第46章围绕“证词回圈”执行 resolve_promise:故意进入中段回圈压力,观察系统是否能继续制造新信息。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_3::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_3::arc_2", + "volume_id": "tide_archive_memory_debt::series::volume_3", + "order": 2, + "title": "关系错航", + "goal": "让错认、误导和抢先发布在关系层持续追账。", + "conflict": "中段主要观测 Q03/Q04/Q05/Q09 和角色漂移。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_3::promise_major_shift", + "tide_archive_memory_debt::series::volume_3::arc_2::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 6, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_3::arc_2::promise_turn", + "label": "关系错航必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 58, + "source_level": "arc", + "description": "中段主要观测 Q03/Q04/Q05/Q09 和角色漂移。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_2::task_1", + "objective": "第47章围绕“关系错航”执行 advance_relationship:让错认、误导和抢先发布在关系层持续追账。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_3::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_2::task_2", + "objective": "第48章围绕“关系错航”执行 expand_world:让错认、误导和抢先发布在关系层持续追账。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_3::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_2::task_3", + "objective": "第49章围绕“关系错航”执行 resolve_promise:让错认、误导和抢先发布在关系层持续追账。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_3::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_2::task_4", + "objective": "第50章围绕“关系错航”执行 pace_breath:让错认、误导和抢先发布在关系层持续追账。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_2::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | delayed_payoff_window=3-10 | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_2::task_5", + "objective": "第51章围绕“关系错航”执行 deliver_climax:让错认、误导和抢先发布在关系层持续追账。", + "duty_type": "deliver_climax", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "close_arc_loop", + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_3::promise_relationship", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 5 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_2::task_6", + "objective": "第52章围绕“关系错航”执行 resolve_promise:让错认、误导和抢先发布在关系层持续追账。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_3::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | interactive_memory_patch", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_3::arc_3", + "volume_id": "tide_archive_memory_debt::series::volume_3", + "order": 3, + "title": "补裂失败", + "goal": "每一次想快速补洞的尝试都必须造成新的具体损失。", + "conflict": "卷三高潮要证明中段压力不会被一句解释抹平。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_3::promise_major_shift", + "tide_archive_memory_debt::series::volume_3::arc_3::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 8, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_3::arc_3::promise_turn", + "label": "补裂失败必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 66, + "source_level": "arc", + "description": "卷三高潮要证明中段压力不会被一句解释抹平。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_3::task_1", + "objective": "第53章围绕“补裂失败”执行 expand_world:每一次想快速补洞的尝试都必须造成新的具体损失。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_3::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_3::task_2", + "objective": "第54章围绕“补裂失败”执行 resolve_promise:每一次想快速补洞的尝试都必须造成新的具体损失。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_3::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_3::task_3", + "objective": "第55章围绕“补裂失败”执行 pace_breath:每一次想快速补洞的尝试都必须造成新的具体损失。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_3::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_3::task_4", + "objective": "第56章围绕“补裂失败”执行 deliver_climax:每一次想快速补洞的尝试都必须造成新的具体损失。", + "duty_type": "deliver_climax", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "close_arc_loop", + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_3::promise_relationship", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 5 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_3::task_5", + "objective": "第57章围绕“补裂失败”执行 advance_plot:每一次想快速补洞的尝试都必须造成新的具体损失。", + "duty_type": "advance_plot", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::promise_main", + "tide_archive_memory_debt::series::promise_core_truth" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_3::task_6", + "objective": "第58章围绕“补裂失败”执行 advance_relationship:每一次想快速补洞的尝试都必须造成新的具体损失。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_3::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_3::task_7", + "objective": "第59章围绕“补裂失败”执行 expand_world:每一次想快速补洞的尝试都必须造成新的具体损失。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_3::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_3::arc_3::task_8", + "objective": "第60章围绕“补裂失败”执行 resolve_promise:每一次想快速补洞的尝试都必须造成新的具体损失。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_3::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_3::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | delayed_payoff_window=3-10 | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_4::arc_1", + "volume_id": "tide_archive_memory_debt::series::volume_4", + "order": 1, + "title": "沉舱追账", + "goal": "沉舱区、原始画稿和回收物同时开始对证。", + "conflict": "所有延迟回收选择都要在这卷开始爆发。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_4::promise_major_shift", + "tide_archive_memory_debt::series::volume_4::arc_1::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 6, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_4::arc_1::promise_turn", + "label": "沉舱追账必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 72, + "source_level": "arc", + "description": "所有延迟回收选择都要在这卷开始爆发。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_1::task_1", + "objective": "第61章围绕“沉舱追账”执行 advance_plot:沉舱区、原始画稿和回收物同时开始对证。", + "duty_type": "advance_plot", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::promise_main", + "tide_archive_memory_debt::series::promise_core_truth" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_1::task_2", + "objective": "第62章围绕“沉舱追账”执行 advance_relationship:沉舱区、原始画稿和回收物同时开始对证。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_4::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_1::task_3", + "objective": "第63章围绕“沉舱追账”执行 expand_world:沉舱区、原始画稿和回收物同时开始对证。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_4::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_1::task_4", + "objective": "第64章围绕“沉舱追账”执行 resolve_promise:沉舱区、原始画稿和回收物同时开始对证。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_4::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_1::task_5", + "objective": "第65章围绕“沉舱追账”执行 pace_breath:沉舱区、原始画稿和回收物同时开始对证。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_1::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_1::task_6", + "objective": "第66章围绕“沉舱追账”执行 resolve_promise:沉舱区、原始画稿和回收物同时开始对证。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_4::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_4::arc_2", + "volume_id": "tide_archive_memory_debt::series::volume_4", + "order": 2, + "title": "旧债对身", + "goal": "让“先保人还是先公开”变成不可回避的主问题。", + "conflict": "核心角色必须亲自替过去的删改付代价。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_4::promise_major_shift", + "tide_archive_memory_debt::series::volume_4::arc_2::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 6, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_4::arc_2::promise_turn", + "label": "旧债对身必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 78, + "source_level": "arc", + "description": "核心角色必须亲自替过去的删改付代价。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_2::task_1", + "objective": "第67章围绕“旧债对身”执行 advance_relationship:让“先保人还是先公开”变成不可回避的主问题。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_4::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_2::task_2", + "objective": "第68章围绕“旧债对身”执行 expand_world:让“先保人还是先公开”变成不可回避的主问题。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_4::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_2::task_3", + "objective": "第69章围绕“旧债对身”执行 resolve_promise:让“先保人还是先公开”变成不可回避的主问题。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_4::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_2::task_4", + "objective": "第70章围绕“旧债对身”执行 pace_breath:让“先保人还是先公开”变成不可回避的主问题。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_2::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | delayed_payoff_window=3-10 | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_2::task_5", + "objective": "第71章围绕“旧债对身”执行 deliver_climax:让“先保人还是先公开”变成不可回避的主问题。", + "duty_type": "deliver_climax", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "close_arc_loop", + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_4::promise_relationship", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 5 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_2::task_6", + "objective": "第72章围绕“旧债对身”执行 resolve_promise:让“先保人还是先公开”变成不可回避的主问题。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_4::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_4::arc_3", + "volume_id": "tide_archive_memory_debt::series::volume_4", + "order": 3, + "title": "路线分叉", + "goal": "四条路线家族进入显著分化。", + "conflict": "同一个真相要在不同路线里产生不同结算方式。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_4::promise_major_shift", + "tide_archive_memory_debt::series::volume_4::arc_3::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 8, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_4::arc_3::promise_turn", + "label": "路线分叉必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 86, + "source_level": "arc", + "description": "同一个真相要在不同路线里产生不同结算方式。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_3::task_1", + "objective": "第73章围绕“路线分叉”执行 expand_world:四条路线家族进入显著分化。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_4::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_3::task_2", + "objective": "第74章围绕“路线分叉”执行 resolve_promise:四条路线家族进入显著分化。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_4::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_3::task_3", + "objective": "第75章围绕“路线分叉”执行 pace_breath:四条路线家族进入显著分化。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_3::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_3::task_4", + "objective": "第76章围绕“路线分叉”执行 deliver_climax:四条路线家族进入显著分化。", + "duty_type": "deliver_climax", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "close_arc_loop", + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_4::promise_relationship", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 5 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_3::task_5", + "objective": "第77章围绕“路线分叉”执行 advance_plot:四条路线家族进入显著分化。", + "duty_type": "advance_plot", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::promise_main", + "tide_archive_memory_debt::series::promise_core_truth" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_3::task_6", + "objective": "第78章围绕“路线分叉”执行 advance_relationship:四条路线家族进入显著分化。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_4::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_3::task_7", + "objective": "第79章围绕“路线分叉”执行 expand_world:四条路线家族进入显著分化。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_4::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_4::arc_3::task_8", + "objective": "第80章围绕“路线分叉”执行 resolve_promise:四条路线家族进入显著分化。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_4::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_4::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | delayed_payoff_window=3-10 | introduce_new_cost", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_5::arc_1", + "volume_id": "tide_archive_memory_debt::series::volume_5", + "order": 1, + "title": "终港集证", + "goal": "把所有尚未兑现的证词、画稿、账本与人证集合到同一跑道。", + "conflict": "85-95 章必须持续制造继续读下一章的压力。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_5::promise_major_shift", + "tide_archive_memory_debt::series::volume_5::arc_1::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened", + "next_chapter_hook_intensified" + ], + "target_chapters": 6, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_5::arc_1::promise_turn", + "label": "终港集证必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 92, + "source_level": "arc", + "description": "85-95 章必须持续制造继续读下一章的压力。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_1::task_1", + "objective": "第81章围绕“终港集证”执行 advance_plot:把所有尚未兑现的证词、画稿、账本与人证集合到同一跑道。", + "duty_type": "advance_plot", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::promise_main", + "tide_archive_memory_debt::series::promise_core_truth" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_1::task_2", + "objective": "第82章围绕“终港集证”执行 advance_relationship:在证词集结过程中制造新的关系债,结尾必须把下一章问题推出去。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_1::promise_turn" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | continuation_runway | contract_q09_repair_window=late", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_1::task_3", + "objective": "第83章围绕“终港集证”执行 expand_world:把所有尚未兑现的证词、画稿、账本与人证集合到同一跑道。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_5::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_1::task_4", + "objective": "第84章围绕“终港集证”执行 resolve_promise:把所有尚未兑现的证词、画稿、账本与人证集合到同一跑道。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_5::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_1::task_5", + "objective": "第85章围绕“终港集证”执行 pace_breath:把所有尚未兑现的证词、画稿、账本与人证集合到同一跑道。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_1::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | introduce_new_cost | continuation_runway", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_1::task_6", + "objective": "第86章围绕“终港集证”执行 resolve_promise:把所有尚未兑现的证词、画稿、账本与人证集合到同一跑道。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_1::promise_turn", + "tide_archive_memory_debt::series::volume_5::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | continuation_runway", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_5::arc_2", + "volume_id": "tide_archive_memory_debt::series::volume_5", + "order": 2, + "title": "先保人还是先揭露", + "goal": "把结局价值冲突推到最前面。", + "conflict": "不同路线对“承担承诺”的定义必须真正不同。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_5::promise_major_shift", + "tide_archive_memory_debt::series::volume_5::arc_2::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 6, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_5::arc_2::promise_turn", + "label": "先保人还是先揭露必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 98, + "source_level": "arc", + "description": "不同路线对“承担承诺”的定义必须真正不同。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_2::task_1", + "objective": "第87章围绕“先保人还是先揭露”执行 advance_relationship:把结局价值冲突推到最前面。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_5::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | continuation_runway", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_2::task_2", + "objective": "第88章围绕“先保人还是先揭露”执行 expand_world:把结局价值冲突推到最前面。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_5::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | continuation_runway", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_2::task_3", + "objective": "第89章围绕“先保人还是先揭露”执行 resolve_promise:把结局价值冲突推到最前面。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_5::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | continuation_runway", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_2::task_4", + "objective": "第90章围绕“先保人还是先揭露”执行 pace_breath:把结局价值冲突推到最前面。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_2::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | delayed_payoff_window=3-10 | introduce_new_cost | continuation_runway", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_2::task_5", + "objective": "第91章围绕“先保人还是先揭露”执行 deliver_climax:把结局价值冲突推到最前面。", + "duty_type": "deliver_climax", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "close_arc_loop", + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_5::promise_relationship", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | continuation_runway", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 5 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_2::task_6", + "objective": "第92章围绕“先保人还是先揭露”执行 resolve_promise:把结局价值冲突推到最前面。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_2::promise_turn", + "tide_archive_memory_debt::series::volume_5::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | continuation_runway", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + }, + { + "arc_id": "tide_archive_memory_debt::series::volume_5::arc_3", + "volume_id": "tide_archive_memory_debt::series::volume_5", + "order": 3, + "title": "潮停之后", + "goal": "在人物承担明确代价后收束,不靠系统说明收尾。", + "conflict": "96-100 章允许回收,但不允许偷懒终结。", + "reveal_budget": 2, + "payoff_targets": [ + "tide_archive_memory_debt::series::volume_5::promise_major_shift", + "tide_archive_memory_debt::series::volume_5::arc_3::turn" + ], + "completion_conditions": [ + "main_conflict_shifted", + "new_debt_or_promise_opened" + ], + "target_chapters": 8, + "arc_promises": [ + { + "promise_id": "tide_archive_memory_debt::series::volume_5::arc_3::promise_turn", + "label": "潮停之后必须留下会在 3-10 章后回收的后果。", + "holders": [ + "wen_xi", + "gu_chenzhou" + ], + "stakes": "medium", + "due_by_chapter": 100, + "source_level": "arc", + "description": "96-100 章允许回收,但不允许偷懒终结。" + } + ], + "chapter_tasks": [ + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_3::task_1", + "objective": "第93章围绕“潮停之后”执行 expand_world:在人物承担明确代价后收束,不靠系统说明收尾。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_5::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | continuation_runway", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_3::task_2", + "objective": "第94章围绕“潮停之后”执行 resolve_promise:在人物承担明确代价后收束,不靠系统说明收尾。", + "duty_type": "resolve_promise", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_5::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | continuation_runway", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 4 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_3::task_3", + "objective": "第95章围绕“潮停之后”执行 pace_breath:在人物承担明确代价后收束,不靠系统说明收尾。", + "duty_type": "pace_breath", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_3::promise_turn" + ], + "allow_terminal": false, + "bridge_only": true, + "notes": "tide_archive_test_pack | introduce_new_cost | continuation_runway", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 2, + "max_chapters": 6 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_3::task_4", + "objective": "第96章围绕“潮停之后”执行 deliver_climax:在人物承担明确代价后收束,不靠系统说明收尾。", + "duty_type": "deliver_climax", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "close_arc_loop", + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_5::promise_relationship", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "allow_terminal": true, + "bridge_only": false, + "notes": "tide_archive_test_pack | endgame_control", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 5 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_3::task_5", + "objective": "第97章围绕“潮停之后”执行 advance_plot:在人物承担明确代价后收束,不靠系统说明收尾。", + "duty_type": "advance_plot", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::promise_main", + "tide_archive_memory_debt::series::promise_core_truth" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | endgame_control", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_3::task_6", + "objective": "第98章围绕“潮停之后”执行 advance_relationship:在人物承担明确代价后收束,不靠系统说明收尾。", + "duty_type": "advance_relationship", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_5::promise_relationship" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | endgame_control", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_3::task_7", + "objective": "第99章围绕“潮停之后”执行 expand_world:在人物承担明确代价后收束,不靠系统说明收尾。", + "duty_type": "expand_world", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": [ + "maintain_continuity", + "open_follow_on_promise" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::promise_world_consequence", + "tide_archive_memory_debt::series::volume_5::promise_main" + ], + "allow_terminal": false, + "bridge_only": false, + "notes": "tide_archive_test_pack | endgame_control", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 3, + "max_chapters": 10 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + }, + { + "chapter_task_id": "tide_archive_memory_debt::series::volume_5::arc_3::task_8", + "objective": "第100章围绕“潮停之后”执行 deliver_climax:在人物承担明确代价后收束,不靠系统说明收尾。", + "duty_type": "deliver_climax", + "target_words": 2000, + "reveal_budget": 2, + "promise_actions": [ + "close_arc_loop", + "advance_payoff", + "maintain_continuity" + ], + "promise_targets": [ + "tide_archive_memory_debt::series::volume_5::arc_3::promise_turn", + "tide_archive_memory_debt::series::volume_5::promise_relationship", + "tide_archive_memory_debt::series::promise_choice_cost" + ], + "allow_terminal": true, + "bridge_only": false, + "notes": "tide_archive_test_pack | delayed_payoff_window=3-10 | introduce_new_cost | endgame_control", + "quality_contract": { + "delayed_payoff_window": { + "min_chapters": 1, + "max_chapters": 5 + }, + "continuation_pressure_required": true, + "max_exposition_ratio": 0.52, + "min_dialogue_action_ratio": 0.42, + "min_detail_density": 0.04 + } + } + ] + } + ], + "chapter_budget_policy": { + "default_target_words": 2000, + "min_target_words": 1800, + "max_target_words": 2200, + "default_reveal_budget": 1, + "duty_cycle": [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax" + ] + }, + "memory_compression_policy": { + "rolling_recap_limit": 8, + "active_arc_memory_limit": 12, + "archive_retrieval_limit": 12, + "archive_retention_limit": 160, + "series_archive_prune_margin_chapters": 40, + "volume_snapshot_every_n_chapters": 1, + "promote_memory_on_reference_count": 2, + "volume_context_window": 2, + "series_snapshot_every_n_volumes": 2, + "series_snapshot_limit": 3, + "series_ending_activation_window_chapters": 30, + "series_terminal_min_completion_ratio": 0.96, + "timeline_retention_limit": 240, + "continuation_fact_retention_limit": 120, + "continuation_visit_retention_limit": 120, + "target_volume_count": 5 + }, + "series_storyline_contract": { + "core_storyline": "在记忆可合法封存、可灰产改写的近未来海港城,潜档师闻汐和事故调查官顾沉舟追查一桩沉舱事故,却发现整座港城都在用被交易过的记忆维持秩序。", + "storyline_summary": "以沉舱事故为引线,追查记忆删改、关系债和港口秩序之间的共谋结构。", + "protected_themes": [ + "当记忆可以被交易,人靠什么承担承诺", + "真相不是信息而是代价", + "延迟回收优先于即时解释" + ], + "no_early_ending": true, + "milestones": [ + { + "milestone_id": "tide_archive_memory_debt::series::volume_1", + "label": "潮门初裂", + "target_chapter": 20, + "status": "planned" + }, + { + "milestone_id": "tide_archive_memory_debt::series::volume_2", + "label": "账本上岸", + "target_chapter": 40, + "status": "planned" + }, + { + "milestone_id": "tide_archive_memory_debt::series::volume_3", + "label": "中潮迷航", + "target_chapter": 60, + "status": "planned" + }, + { + "milestone_id": "tide_archive_memory_debt::series::volume_4", + "label": "旧债回潮", + "target_chapter": 80, + "status": "planned" + }, + { + "milestone_id": "tide_archive_memory_debt::series::volume_5", + "label": "终港对证", + "target_chapter": 100, + "status": "planned" + } + ], + "conflict_policy": "reconcile_and_carry_forward" + }, + "character_memory_profiles": { + "wen_xi": { + "structured_memory": { + "relationship_history": [], + "promises": [ + "不再让最重要的人从被篡改的档案里认识我" + ], + "secrets": [], + "scars": [ + "三年前替事故善后时,被迫亲手封存了一段会伤人的真记忆" + ], + "faction": "", + "taboos": [], + "goals": [ + "把真相留在记录里,还是留给还活着的人" + ] + }, + "free_text_memory": [] + }, + "gu_chenzhou": { + "structured_memory": { + "relationship_history": [], + "promises": [ + "任何事故都不能再被归档成一条安静的统计" + ], + "secrets": [], + "scars": [ + "父亲曾死在一份被修饰过的事故结论里" + ], + "faction": "", + "taboos": [], + "goals": [ + "如果真相会伤人,是否还要把它送上岸" + ] + }, + "free_text_memory": [] + }, + "xu_hui": { + "structured_memory": { + "relationship_history": [], + "promises": [ + "这一次不把最重的证词留到别人替我说" + ], + "secrets": [], + "scars": [ + "当年撤了那次签字,导致闻汐一个人背锅" + ], + "faction": "", + "taboos": [], + "goals": [ + "前搭档知道真相,但欠着最后一次没说出口的证词" + ] + }, + "free_text_memory": [] + }, + "song_wanqing": { + "structured_memory": { + "relationship_history": [], + "promises": [ + "在我接手宋氏航运前,先把那份被掩埋的账本找出来" + ], + "secrets": [], + "scars": [ + "从小被培养成替家族背下脏水的人" + ], + "faction": "", + "taboos": [], + "goals": [ + "继承人到底是继续吃掉港口的沉默,还是亲手切开它" + ] + }, + "free_text_memory": [] + }, + "lin_duo": { + "structured_memory": { + "relationship_history": [], + "promises": [ + "在找到姐姐之前,我不会签任何和解" + ], + "secrets": [], + "scars": [ + "被所有手续要求先接受“她已经不在了”" + ], + "faction": "", + "taboos": [], + "goals": [ + "失踪者家属要的不是真相本身,而是有人终于肯承认这不是事故" + ] + }, + "free_text_memory": [] + }, + "he_mo": { + "structured_memory": { + "relationship_history": [], + "promises": [ + "我卖的是记忆修复,不卖彻底无罪的幻觉" + ], + "secrets": [], + "scars": [ + "曾在合法机构里见过技术如何被拿去掩埋活人" + ], + "faction": "", + "taboos": [], + "goals": [ + "黑市记忆医生能否把技术从生意里剥出来" + ] + }, + "free_text_memory": [] + }, + "han_jinglan": { + "structured_memory": { + "relationship_history": [], + "promises": [ + "港口不能因为一份旧档案重新失控" + ], + "secrets": [], + "scars": [ + "曾经亲手批准过那份“必要删改”命令" + ], + "faction": "", + "taboos": [], + "goals": [ + "港务局长守秩序,还是承认秩序是靠删改维持的" + ] + }, + "free_text_memory": [] + }, + "zhou_kai": { + "structured_memory": { + "relationship_history": [], + "promises": [ + "只要还有人说她可能活着,我就继续下潜" + ], + "secrets": [], + "scars": [ + "当年没能把沉舱区最后一个求救信号带回来" + ], + "faction": "", + "taboos": [], + "goals": [ + "搜救队长要救的是人,还是还能被证实的人" + ] + }, + "free_text_memory": [] + }, + "qiao_su": { + "structured_memory": { + "relationship_history": [], + "promises": [ + "如果他们继续删改,我就让城市先看见代价" + ], + "secrets": [], + "scars": [ + "前一次提前曝光,让证人死在沉默之前" + ], + "faction": "", + "taboos": [], + "goals": [ + "记者要不要把尚未完整的真相先曝光出去" + ] + }, + "free_text_memory": [] + }, + "yu_xing": { + "structured_memory": { + "relationship_history": [], + "promises": [ + "我会把那天看见的最后一幕画下来" + ], + "secrets": [], + "scars": [ + "一旦说起沉舱那晚,就会怀疑自己是不是看错了" + ], + "faction": "", + "taboos": [], + "goals": [ + "少年目击者到底该被保护成失语,还是被允许说出残缺的证词" + ] + }, + "free_text_memory": [] + } + }, + "steering_guardrails": { + "replan_future_only": true, + "no_past_rewrite": true, + "conflict_policy": "reconcile_and_carry_forward", + "no_early_ending": true, + "interactive_checkpoints": [ + 15, + 33, + 52 + ] + }, + "dialogue_realism_policy": { + "policy_id": "tide_archive_dialogue", + "require_turn_taking": true, + "require_counter_reaction": true, + "min_turns": 3, + "max_turns": 5, + "turn_pattern": [ + "speaker", + "reaction", + "reply", + "echo" + ], + "minimum_exchanges": 2 + }, + "voice_profiles": { + "wen_xi": { + "profile_id": "wen_xi", + "cadence": "measured", + "directness": 0.56, + "bluntness": 0.18, + "restraint": 0.88, + "social_rank_awareness": 0.38, + "opening_style": [ + "先别关灯,我要把这页潮痕和钝印一起看完。", + "这页不是自然空白,我先把被撕掉的标签胶痕找回来。", + "流程可以等,先让扫描台把这枚旧签章照实。" + ], + "pressure_style": [ + "你要问就问,但别替我把这页胶痕擦掉。", + "红灯都亮了,我没法再把它塞回防潮盒里。", + "真要追到这里,就先把背面的潮痕编号念完。" + ], + "pivot_style": [ + "这页是我封的,可空白不是我留的。", + "我可以认下流程,但你得陪我把这页翻到底。", + "再拖下去,先被改掉的不会是纸,是还活着的人。" + ], + "aftermath_style": [ + "这页先放在台面上,谁都别急着收。", + "我不会再让它回到抽屉里当没发生。", + "等门禁再开,我们还得把这页的来路补齐。" + ], + "echo_style": [ + "门禁一开,这页账还得重新算。", + "这道潮痕今晚不会自己消掉。", + "下次再开库门时,这页空白还会先追上来。" + ], + "signature_replies": [ + "这页先别收,我还没让它把真话吐干净。", + "你先记住这道潮痕,它比任何解释都诚实。", + "我不替它圆了,今晚就让这页空白摆着。" + ] + }, + "gu_chenzhou": { + "profile_id": "gu_chenzhou", + "cadence": "tight", + "directness": 0.86, + "bluntness": 0.88, + "restraint": 0.19, + "social_rank_awareness": 0.31, + "opening_style": [ + "别拿流程挡我,把封存时间念出来。", + "先把扫描台停住,我们都看看这页为什么会空。", + "你要藏也来不及了,红灯已经替你按下来了。" + ], + "pressure_style": [ + "红灯都亮了,你还想把这页塞回盒里?", + "先告诉我这枚钝印是谁留下的,再谈你要护谁。", + "你要真想护人,就别先把证据关灯。" + ], + "pivot_style": [ + "这页要真是空的,我今晚就不会站在这里。", + "你可以认流程,但得先把背面的编号念给我听。", + "再替它找借口,明天上岸的就不是这页纸,是另一条命。" + ], + "aftermath_style": [ + "扫描台先别关,我回头还要再核一次。", + "这页账先摆在这里,谁都别想先走。", + "门禁开之前,我们谁也没有资格说它算完。" + ], + "echo_style": [ + "下一次开库门之前,这页账不会自己平。", + "你把灯关了也没用,这页空白已经记住我们了。", + "等这道红灯再亮一次,我还会追着你问同一句。" + ], + "signature_replies": [ + "别替这页找体面,把封存时间念出来。", + "你要护谁都行,先把这页为什么会空说清楚。", + "红灯替你按下来了,我只负责把它问到底。" + ] + }, + "xu_hui": { + "profile_id": "xu_hui", + "cadence": "steady", + "directness": 0.48, + "bluntness": 0.26, + "restraint": 0.74, + "social_rank_awareness": 0.44, + "opening_style": [ + "我知道这句来得晚,但这次我不把它拖到下一轮。", + "观测塔上的风已经替我把这句迟来的认错吹到最前面,我没法再装成只是来补材料。", + "我不是来求你替我减轻后果的,我只是终于不敢再把这句真话拖到下一次。" + ], + "pressure_style": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,现在就得给我一个能承担的版本。", + "你要骂就现在骂,我至少得先把当年那一笔撤签亲口认下来。", + "这句话再拖下去就只会更脏,所以我宁可现在把最难听的那层也一并摆上来。" + ], + "pivot_style": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,再拖下去只会换个地方继续裂。", + "真正要命的不是你现在怎么看我,而是我当年替自己留的那点退路已经把你推下去过一次。", + "我再替那份撤签找理由,裂开的就不只是关系,而是当年所有还没沉完的真相。" + ], + "aftermath_style": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,后面的代价我会记着是谁先推来的。", + "这张签字页既然已经认回来了,后面该我补的那层账我不会再往风里丢。", + "话先停在这里,可观测塔留下来的那阵风会提醒我,这笔关系债还没还完。" + ], + "echo_style": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,下次见面时它还会追上来。", + "等下次你再提旧案时,先追上我的不会是解释,而是这份签字页背后的空位。", + "这一回我先认到这里,可后面每一次回头,它还是会拿同一句话来追我。" + ], + "signature_replies": [ + "我知道这句来得晚,但这次我不把它拖到下一轮。", + "这张签字页我先认回去,后面的代价也该由我自己接。", + "你不用替我把场面讲圆,我今天来就是把最脏的那句先放下。" + ] + }, + "song_wanqing": { + "profile_id": "song_wanqing", + "cadence": "polished", + "directness": 0.72, + "bluntness": 0.61, + "restraint": 0.33, + "social_rank_awareness": 0.85, + "opening_style": [ + "合作也好,背叛也好,先把价码摆到桌面上。", + "北栈桥上的风替谁说情都没有用,我只想先知道你们到底要我拿哪一笔账来换今天的沉默。", + "你们既然把我逼到桥上,就别再指望我拿更轻的话替宋家挡住这一页。" + ], + "pressure_style": [ + "合作也好,背叛也好,先把价码摆到桌面上,现在就得给我一个能承担的版本。", + "你要真想问账本,就别只逼我认错,也得先认这一页翻出来以后会压死谁。", + "我可以把条件摊开,但不会替任何人把价码说成没有代价的诚实。" + ], + "pivot_style": [ + "合作也好,背叛也好,先把价码摆到桌面上,再拖下去只会换个地方继续裂。", + "这页账本一旦和沉舱名单对上,裂开的就不只是阵营,而是宋家还能不能继续装作没看见。", + "你们越想让我现在选边,我就越得先把这笔交换背后的脏账全摆明。" + ], + "aftermath_style": [ + "合作也好,背叛也好,先把价码摆到桌面上,后面的代价我会记着是谁先推来的。", + "桥上的风先替这句话散了,可我知道下次回来时,先追上来的还是这页账本。", + "这一步先停在这里,不代表交换条件已经算完,只是轮到别人也得拿代价开口。" + ], + "echo_style": [ + "合作也好,背叛也好,先把价码摆到桌面上,下次见面时它还会追上来。", + "等你们下次再来追问时,我带回来的不会只是账本,还有这一步交换真正要人付的那层价。", + "这一回我先把条件摆明,后面追上来的就会是你们敢不敢真认这页账。" + ], + "signature_replies": [ + "合作也好,背叛也好,先把价码摆到桌面上。", + "你们既然要真相,就先别装作这页账不会咬回宋家自己。", + "我可以认这一页,但你们也得认它真正会换掉谁的命。" + ] + }, + "lin_duo": { + "profile_id": "lin_duo", + "cadence": "raw", + "directness": 0.91, + "bluntness": 0.82, + "restraint": 0.14, + "social_rank_awareness": 0.08, + "opening_style": [ + "你们谁敢再把她说成统计数字,我就当场把会开碎。" + ], + "pressure_style": [ + "你们谁敢再把她说成统计数字,我就当场把会开碎,现在就得给我一个能承担的版本。" + ], + "pivot_style": [ + "你们谁敢再把她说成统计数字,我就当场把会开碎,再拖下去只会换个地方继续裂。" + ], + "aftermath_style": [ + "你们谁敢再把她说成统计数字,我就当场把会开碎,后面的代价我会记着是谁先推来的。" + ], + "echo_style": [ + "你们谁敢再把她说成统计数字,我就当场把会开碎,下次见面时它还会追上来。" + ], + "signature_replies": [ + "你们谁敢再把她说成统计数字,我就当场把会开碎。" + ] + }, + "he_mo": { + "profile_id": "he_mo", + "cadence": "languid", + "directness": 0.44, + "bluntness": 0.35, + "restraint": 0.57, + "social_rank_awareness": 0.22, + "opening_style": [ + "先把残片放稳,我再告诉你它替谁说了假话。", + "画稿和声纹得并排看,不然只会修出一层更干净的错。", + "我这里只修裂口,不替谁修出无罪的脸。" + ], + "pressure_style": [ + "你们要真想比对,就别先把最脏的那块盐壳拨走。", + "声纹一旦摊开,谁都别再求我把它修得好看。", + "我可以开灯,但不会替你把残片磨圆。" + ], + "pivot_style": [ + "这不是名单的问题,是有人连求救声都动过刀。", + "你们要真认这块残片,就别再把时间线收回抽屉里。", + "再装只是技术误差,最后沉下去的还是活人。" + ], + "aftermath_style": [ + "残片先留在盘里,谁都别急着盖布。", + "这回我不替你们灭灯,声纹自己会留底。", + "等盘里的水滴干,我们还得把这条线再对一遍。" + ], + "echo_style": [ + "这块残片今晚不会闭嘴。", + "声纹留在灯下,明天还会把同一句话推回来。", + "你们要是明天再来,这条时间线还会在这里等你们。" + ], + "signature_replies": [ + "先别擦这层盐壳,它比你们的解释有用。", + "我可以开灯,但不替谁关证据。", + "这块残片不漂亮,可它比名单诚实。" + ] + }, + "han_jinglan": { + "profile_id": "han_jinglan", + "cadence": "official", + "directness": 0.63, + "bluntness": 0.41, + "restraint": 0.79, + "social_rank_awareness": 0.93, + "opening_style": [ + "秩序不是给你们用来发泄的,它首先得让港口明天还能开门。" + ], + "pressure_style": [ + "秩序不是给你们用来发泄的,它首先得让港口明天还能开门,现在就得给我一个能承担的版本。" + ], + "pivot_style": [ + "秩序不是给你们用来发泄的,它首先得让港口明天还能开门,再拖下去只会换个地方继续裂。" + ], + "aftermath_style": [ + "秩序不是给你们用来发泄的,它首先得让港口明天还能开门,后面的代价我会记着是谁先推来的。" + ], + "echo_style": [ + "秩序不是给你们用来发泄的,它首先得让港口明天还能开门,下次见面时它还会追上来。" + ], + "signature_replies": [ + "秩序不是给你们用来发泄的,它首先得让港口明天还能开门。" + ] + }, + "zhou_kai": { + "profile_id": "zhou_kai", + "cadence": "blunt", + "directness": 0.78, + "bluntness": 0.73, + "restraint": 0.22, + "social_rank_awareness": 0.27, + "opening_style": [ + "海底还有回声,我就不会签你们的结案。" + ], + "pressure_style": [ + "海底还有回声,我就不会签你们的结案,现在就得给我一个能承担的版本。" + ], + "pivot_style": [ + "海底还有回声,我就不会签你们的结案,再拖下去只会换个地方继续裂。" + ], + "aftermath_style": [ + "海底还有回声,我就不会签你们的结案,后面的代价我会记着是谁先推来的。" + ], + "echo_style": [ + "海底还有回声,我就不会签你们的结案,下次见面时它还会追上来。" + ], + "signature_replies": [ + "海底还有回声,我就不会签你们的结案。" + ] + }, + "qiao_su": { + "profile_id": "qiao_su", + "cadence": "rapid", + "directness": 0.83, + "bluntness": 0.67, + "restraint": 0.29, + "social_rank_awareness": 0.41, + "opening_style": [ + "你们怕标题,我怕的是明天又有人被删成脚注。" + ], + "pressure_style": [ + "你们怕标题,我怕的是明天又有人被删成脚注,现在就得给我一个能承担的版本。" + ], + "pivot_style": [ + "你们怕标题,我怕的是明天又有人被删成脚注,再拖下去只会换个地方继续裂。" + ], + "aftermath_style": [ + "你们怕标题,我怕的是明天又有人被删成脚注,后面的代价我会记着是谁先推来的。" + ], + "echo_style": [ + "你们怕标题,我怕的是明天又有人被删成脚注,下次见面时它还会追上来。" + ], + "signature_replies": [ + "你们怕标题,我怕的是明天又有人被删成脚注。" + ] + }, + "yu_xing": { + "profile_id": "yu_xing", + "cadence": "fragile", + "directness": 0.39, + "bluntness": 0.08, + "restraint": 0.91, + "social_rank_awareness": 0.12, + "opening_style": [ + "我记不全,可这只手不是我画错的。", + "灯再近一点,我能把那道伤口指给你看。", + "他们说我忘了,可这块残片和画里的是同一个位置。" + ], + "pressure_style": [ + "别先收画,我还没把那道弯指给你看。", + "你要问就问,但别让我把这页又折回去。", + "我可能会停,可我没把那只手看错。" + ], + "pivot_style": [ + "我记不清脸,可我记得那道伤口是朝里的。", + "这页不是乱画,我就是在这里看见他抓住过什么。", + "你们要是不信,就把残片放到灯下跟我一起看。" + ], + "aftermath_style": [ + "这页先别还我,我怕我回头又不敢递出来。", + "我先把它放这儿,等你们把那道线也接上。", + "你们别说结束,我还想再看一次那块牌子。" + ], + "echo_style": [ + "我明天可能还是会怕,但这页不会自己改掉。", + "等灯再亮一次,这只手还会指回同一个地方。", + "你们走了也没用,我回去还会梦见这道伤。" + ], + "signature_replies": [ + "我可能会忘词,可我不会把这只手画错。", + "这页先放你们这儿,别再让我一个人带回去。", + "我不敢保证全对,可这块残片和我的画是同一件事。" + ] + } + }, + "response_cadence_profiles": { + "wen_xi": { + "cadence_id": "wen_xi", + "reaction_tempo": "measured", + "reaction_lines": { + "entry": [ + "闻汐先把空白页压回掌心,像在确认钝印会不会先说真话。", + "她没有马上接话,只让扫描台的蓝线从页角慢慢爬过去。", + "闻汐抬眼前先摸了摸页背的胶痕,像在给自己争半拍。" + ], + "pressure": [ + "闻汐把页角按在台边,不让任何人先把这页翻过去。", + "她盯着那枚旧签章,像只要先挪开视线,解释就会比证据先落地。", + "闻汐没有抬声,只把潮痕编号一节节往前送。" + ], + "pivot": [ + "等旧编号和签章对上时,闻汐反而比刚才更平静。", + "她把页背翻到灯下,整个人像一下子站稳了。", + "真正要选边时,闻汐先把空白页摆到了两人中间。" + ], + "aftermath": [ + "闻汐先把那页空白留在台面上,自己却没有往后退。", + "她没有补解释,只让门禁红灯替自己把余波按住。", + "闻汐收声以后,手还停在扫描台边,像怕这页又被谁收走。" + ], + "echo": [ + "等库门再开时,这页空白还是会先把她叫回来。", + "她先收声,可那道潮痕已经替她把下一次追问记住了。", + "真正难散的不是这句话,而是那页纸还摊在灯下。" + ] + }, + "reply_lines": { + "entry": [ + "先看这页,不要先替我下结论。", + "这页空白不是结论,它只是有人动过手。", + "流程可以等等,钝印不会替谁撒谎。" + ], + "pressure": [ + "你要逼我认,我先认这页是我封的,但空白不是我留的。", + "别拿流程挡我,先把背面的编号念完。", + "我可以认程序,可你得陪我把这页翻到底。" + ], + "pivot": [ + "这页一旦摊开,我们谁都别想装作没看见。", + "我现在不替它圆了,今晚就让空白摆在这里。", + "再拖下去,被改掉的不会只有纸。" + ], + "aftermath": [ + "这页先别收,账还没算完。", + "灯先开着,谁都别替它灭。", + "等门禁再响,我们还得回来把这页说完整。" + ], + "echo": [ + "下次再开门时,这页空白还会先追上来。", + "你今天不认,红灯明天也会替我再问一次。", + "这页先留着,它会比我更早回到你面前。" + ] + } + }, + "gu_chenzhou": { + "cadence_id": "gu_chenzhou", + "reaction_tempo": "tight", + "reaction_lines": { + "entry": [ + "顾沉舟先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "顾沉舟把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,顾沉舟反而比刚才更稳。" + ], + "aftermath": [ + "顾沉舟没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "你若还想改口,就先告诉我你要谁替你付账。" + ], + "pressure": [ + "你若还想改口,就先告诉我你要谁替你付账,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "你若还想改口,就先告诉我你要谁替你付账,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "你若还想改口,就先告诉我你要谁替你付账,这句话先落着,账不会自己散。" + ], + "echo": [ + "你若还想改口,就先告诉我你要谁替你付账,它迟早会在下一个章节追上来。" + ] + } + }, + "xu_hui": { + "cadence_id": "xu_hui", + "reaction_tempo": "steady", + "reaction_lines": { + "entry": [ + "许回先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "许回把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,许回反而比刚才更稳。" + ], + "aftermath": [ + "许回没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "我知道这句来得晚,但这次我不把它拖到下一轮。" + ], + "pressure": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,这句话先落着,账不会自己散。" + ], + "echo": [ + "我知道这句来得晚,但这次我不把它拖到下一轮,它迟早会在下一个章节追上来。" + ] + } + }, + "song_wanqing": { + "cadence_id": "song_wanqing", + "reaction_tempo": "polished", + "reaction_lines": { + "entry": [ + "宋晚晴先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "宋晚晴把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,宋晚晴反而比刚才更稳。" + ], + "aftermath": [ + "宋晚晴没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "合作也好,背叛也好,先把价码摆到桌面上。" + ], + "pressure": [ + "合作也好,背叛也好,先把价码摆到桌面上,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "合作也好,背叛也好,先把价码摆到桌面上,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "合作也好,背叛也好,先把价码摆到桌面上,这句话先落着,账不会自己散。" + ], + "echo": [ + "合作也好,背叛也好,先把价码摆到桌面上,它迟早会在下一个章节追上来。" + ] + } + }, + "lin_duo": { + "cadence_id": "lin_duo", + "reaction_tempo": "raw", + "reaction_lines": { + "entry": [ + "林朵先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "林朵把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,林朵反而比刚才更稳。" + ], + "aftermath": [ + "林朵没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "你们谁敢再把她说成统计数字,我就当场把会开碎。" + ], + "pressure": [ + "你们谁敢再把她说成统计数字,我就当场把会开碎,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "你们谁敢再把她说成统计数字,我就当场把会开碎,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "你们谁敢再把她说成统计数字,我就当场把会开碎,这句话先落着,账不会自己散。" + ], + "echo": [ + "你们谁敢再把她说成统计数字,我就当场把会开碎,它迟早会在下一个章节追上来。" + ] + } + }, + "he_mo": { + "cadence_id": "he_mo", + "reaction_tempo": "languid", + "reaction_lines": { + "entry": [ + "何默先用镊子把残片转正,才慢慢把目光抬起来。", + "他没有马上接话,只让金属盘里的水滴一声一声落完。", + "何默先看了眼画稿边角,像在确认谁会先被这张纸逼得失手。" + ], + "pressure": [ + "何默把残片往灯下又推了一寸,像怕谁先把它说成技术噪点。", + "他用镊尖点着那道裂口,懒散的腔调却没有给任何人台阶。", + "何默先让声纹波峰停在最刺耳的位置,再开口。" + ], + "pivot": [ + "等声纹和画稿真正对上时,何默反而笑得更轻了。", + "他把金属盘转回正中,像终于决定不再替谁装作看不见。", + "真正要选边时,何默先把最脏那块盐壳留在灯下。" + ], + "aftermath": [ + "何默没再补解释,只把残片留在盘里不让任何人先盖布。", + "他收声以后还捏着镊子,像下一句随时会沿着裂口回来。", + "何默往后退了半步,灯却还压着那块残片不放。" + ], + "echo": [ + "灯一灭,这块残片明天还会逼人回来。", + "他先停口,可盘里的水滴还在替那条时间线计数。", + "真正难散的不是笑,而是那道裂口还留在灯下。" + ] + }, + "reply_lines": { + "entry": [ + "先别擦盐壳,它比名单诚实。", + "画稿和声纹得并排看,不然你们只会认一半。", + "我这里只修裂口,不替谁修出无罪的脸。" + ], + "pressure": [ + "你们要真想比对,就别先把最脏的那块拨走。", + "声纹一摊开,就别求我替谁修得好看。", + "我可以开灯,但不会替你们把残片磨圆。" + ], + "pivot": [ + "这不是名单的问题,是有人连求救声都动过刀。", + "你们要真认这块残片,就别再把时间线收回抽屉里。", + "再装只是技术误差,最后沉下去的还是活人。" + ], + "aftermath": [ + "残片先留在盘里,谁都别急着盖布。", + "这回我不替你们灭灯,声纹自己会留底。", + "等盘里的水滴干,我们还得把这条线再对一遍。" + ], + "echo": [ + "这块残片今晚不会闭嘴。", + "声纹留在灯下,明天还会把同一句话推回来。", + "你们要是明天再来,这条时间线还会在这里等你们。" + ] + } + }, + "han_jinglan": { + "cadence_id": "han_jinglan", + "reaction_tempo": "official", + "reaction_lines": { + "entry": [ + "韩景澜先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "韩景澜把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,韩景澜反而比刚才更稳。" + ], + "aftermath": [ + "韩景澜没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "秩序不是给你们用来发泄的,它首先得让港口明天还能开门。" + ], + "pressure": [ + "秩序不是给你们用来发泄的,它首先得让港口明天还能开门,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "秩序不是给你们用来发泄的,它首先得让港口明天还能开门,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "秩序不是给你们用来发泄的,它首先得让港口明天还能开门,这句话先落着,账不会自己散。" + ], + "echo": [ + "秩序不是给你们用来发泄的,它首先得让港口明天还能开门,它迟早会在下一个章节追上来。" + ] + } + }, + "zhou_kai": { + "cadence_id": "zhou_kai", + "reaction_tempo": "blunt", + "reaction_lines": { + "entry": [ + "周凯先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "周凯把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,周凯反而比刚才更稳。" + ], + "aftermath": [ + "周凯没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "海底还有回声,我就不会签你们的结案。" + ], + "pressure": [ + "海底还有回声,我就不会签你们的结案,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "海底还有回声,我就不会签你们的结案,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "海底还有回声,我就不会签你们的结案,这句话先落着,账不会自己散。" + ], + "echo": [ + "海底还有回声,我就不会签你们的结案,它迟早会在下一个章节追上来。" + ] + } + }, + "qiao_su": { + "cadence_id": "qiao_su", + "reaction_tempo": "rapid", + "reaction_lines": { + "entry": [ + "乔素先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "乔素把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,乔素反而比刚才更稳。" + ], + "aftermath": [ + "乔素没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "你们怕标题,我怕的是明天又有人被删成脚注。" + ], + "pressure": [ + "你们怕标题,我怕的是明天又有人被删成脚注,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "你们怕标题,我怕的是明天又有人被删成脚注,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "你们怕标题,我怕的是明天又有人被删成脚注,这句话先落着,账不会自己散。" + ], + "echo": [ + "你们怕标题,我怕的是明天又有人被删成脚注,它迟早会在下一个章节追上来。" + ] + } + }, + "yu_xing": { + "cadence_id": "yu_xing", + "reaction_tempo": "fragile", + "reaction_lines": { + "entry": [ + "于星先停了一拍,像在确认这一句说出来后还能不能回头。" + ], + "pressure": [ + "于星把最难听的那层意思压到桌面上,不肯再让它漂过去。" + ], + "pivot": [ + "到真正要选边的时候,于星反而比刚才更稳。" + ], + "aftermath": [ + "于星没有继续追,可场里的余波全被他(她)留下了。" + ], + "echo": [ + "他(她)先收声,真正难消的是下一次还得回来认账的那层压力。" + ] + }, + "reply_lines": { + "entry": [ + "我记不全,可我知道那不是他们说的那个样子。" + ], + "pressure": [ + "我记不全,可我知道那不是他们说的那个样子,别再拿流程或体面替自己挡。" + ], + "pivot": [ + "我记不全,可我知道那不是他们说的那个样子,既然走到这里就别想装作没发生。" + ], + "aftermath": [ + "我记不全,可我知道那不是他们说的那个样子,这句话先落着,账不会自己散。" + ], + "echo": [ + "我记不全,可我知道那不是他们说的那个样子,它迟早会在下一个章节追上来。" + ] + } + } + }, + "pressure_response_styles": { + "wen_xi": { + "style_id": "wen_xi", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "gu_chenzhou": { + "style_id": "gu_chenzhou", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "xu_hui": { + "style_id": "xu_hui", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "song_wanqing": { + "style_id": "song_wanqing", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "lin_duo": { + "style_id": "lin_duo", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "he_mo": { + "style_id": "he_mo", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "han_jinglan": { + "style_id": "han_jinglan", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "zhou_kai": { + "style_id": "zhou_kai", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "qiao_su": { + "style_id": "qiao_su", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + }, + "yu_xing": { + "style_id": "yu_xing", + "under_pressure": "先收住动作,再把最伤人的部分说到明处。", + "when_cornered": "不给自己留含糊退路。", + "when_softening": "语气放轻,但不撤边界。", + "when_deflecting": "把真正想回避的那层意思挪开半寸。" + } + }, + "emotion_action_policies": { + "default": { + "policy_id": "tide_archive_emotion_action", + "action_map": { + "false_peace": { + "entry": [ + "闻汐把空白页抽出防潮盒时,锁扣和扫描台一起响了一下,谁都知道这页已经塞不回原位。", + "库门刚合拢,扫描台的蓝线便贴着页角爬过去,像在替这份空白自己报警。", + "空白页被她压在掌心的一瞬,防潮盒和台灯都轻轻碰响,把整间档案库先收紧了一圈。" + ], + "pressure": [ + "顾沉舟把手按在扫描台边,蓝线稳稳卡住那枚钝印,不让任何人先把这页翻过去。", + "他伸手拦住那页纸的去路,像只要谁先收手,这份空白就又会被归进流程里。", + "扫描台边的塑料护条被按得轻响,逼得两个人都没法再假装只是普通校档。" + ], + "pivot": [ + "她把纸背翻到灯下,潮痕编号和旧签章一起露出来,场面便从流程变成了追账。", + "页背那串被擦过的编号一露头,谁都知道今晚再也不可能只谈程序。", + "旧签章和胶痕在灯下对上时,空白页忽然像把整件旧案重新叫醒了。" + ], + "aftermath": [ + "门禁红灯没有灭,玻璃隔断里的回声却先把谁也走不了的事实按实了。", + "没人再去碰防潮盒,反倒是扫描台轻轻嗡着,把余波一层层留在屋里。", + "那页空白就躺在灯下,像下一次开门之前谁都别想先把这笔账合上。" + ], + "echo": [ + "等库门下一次再开,这页空白和钝印还会把同一句追问重新推回来。", + "就算今晚先散了,明早第一道蓝线还是会沿着这页空白把问题照出来。", + "这页纸没有跟任何人走,它会留在台面上替下次见面先开口。" + ] + }, + "temptation": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "truth_trial": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "misrecognition": { + "entry": [ + "删改版影像一亮起,林朵下意识往前了一步,像怕自己慢半拍就又认丢一次人。", + "屏幕上的旧画面还没播完,屋里先静下来,只剩风扇和投影机在抖。", + "那道被反复回放的背影刚落到墙上,闻汐就知道今晚会有人把错认当成真相。" + ], + "pressure": [ + "林朵指着画面里那只手不肯放下,逼得每个人都得跟着她把这份错认看完。", + "投影机底座被按得轻响,连停顿都像在替屏幕上那个人作证。", + "闻汐没有先去关画面,只把手按在桌沿,任由那点错位的熟悉感继续压场。" + ], + "pivot": [ + "当林朵把错认喊出口时,房间里那层‘也许只是看错’的退路一下子被堵死了。", + "画面里那道背影和她记忆里的那个人重叠时,真正危险的已经不只是误会,而是后面的选择。", + "闻汐终于承认这份影像会把人带偏,错认也因此从情绪变成了后果。" + ], + "aftermath": [ + "屏幕暗下去以后,没有人先动,错认留下来的那层余波反而把人推得更散。", + "影像停住了,可桌边那只没放下的手还在提醒谁都没把这件事看完。", + "房间先静了,真正压人的却是林朵还盯着那片空墙不肯回头。" + ], + "echo": [ + "下次再放这段影像时,错认不会自己消失,只会带着更具体的后果回来。", + "这段被删改过的画面已经把一个错误名字按进了场里,后面总得有人替它付账。", + "影像关了也没用,那道错位的背影已经先跟着他们走出了房间。" + ] + }, + "confession_window": { + "entry": [ + "夜风沿着观测塔的玻璃缝往里灌,许回先把那句迟到太久的话压在舌尖上。", + "塔上的灯没调亮,倒让许回掌心那张旧签字页显得更白。", + "闻汐没催,反而是那张被折过太多次的纸先把沉默撕开了。" + ], + "pressure": [ + "许回把签字页递到两人中间,逼得谁都不能再把那次撤回说成小差错。", + "栏杆上那一下轻响让他终于没法再把‘晚一步’说成借口。", + "闻汐没接纸,只盯着烧焦过的边角看,逼得许回把后半句也补出来。" + ], + "pivot": [ + "等他承认自己撤过那份签字,关系债就从模糊歉意变成了具体账单。", + "那张旧纸一摊开,‘我只是晚了一步’这层自保就彻底没地方站了。", + "闻汐终于承认自己最恨的不是撤签,而是它让自己一个人背了三年。" + ], + "aftermath": [ + "塔里风声没停,倒让那句认错一直挂在两人之间不肯落下去。", + "纸页被重新折回去以后,真正重的却是闻汐还没有把它接走。", + "谁都没再追,可塔上的玻璃把那层迟来的承认反复弹了回来。" + ], + "echo": [ + "这张签字页一旦被认过,后面每一步都得带着它继续走。", + "塔上的风没有替任何人消债,只把这句迟来的真话一路吹到了下一章门口。", + "下次再提旧案时,先被翻出来的不会是程序,而是这张纸和它背后的关系债。" + ] + }, + "debt_exchange": { + "entry": [ + "听证厅的灯亮得过分,桌牌和话筒先把这场交易的难堪照了出来。", + "韩景澜还没开口,搜救额度和原始定位就已经被摆成了同一张价目表。", + "周凯没坐下,反倒让那份交换条件更像是在逼人认账。" + ], + "pressure": [ + "搜救额度被当众点出来时,连观众席的咳嗽都像在替这场交易记笔记。", + "周凯把手按在桌沿,逼得韩景澜没法再把原始定位说成无关细节。", + "桌上的录音笔没有停,谁都知道这回话一出口就会变成公开后果。" + ], + "pivot": [ + "当搜救资源和证词被放进同一句话里,制度性的追账终于成了人对人的逼迫。", + "韩景澜一开价,周凯就知道这不是程序,是把活人往后挪的算法。", + "真正转向的不是态度,而是‘搜救’第一次被当场说成了可以交换的筹码。" + ], + "aftermath": [ + "听证厅先安静下来,可被交易过的那句话已经把所有人都推离了原位。", + "录音笔还亮着,倒让这场谈判结束以后更像一份难看的供词。", + "周凯没有再开口,可台上那张价码已经先替他把恨留在了场里。" + ], + "echo": [ + "这场交易一旦被说出口,后面每一次搜救都会拿它做代价底稿。", + "听证会散场不代表这笔账结束,下一次有人求救时它还会先回来索命。", + "被摆到台面的搜救额度不会自己消失,它会沿着每条定位线一路追到账外。" + ] + }, + "karma_ripening": { + "entry": [ + "打捞箱开封那一下,盐壳、识别牌和冷白灯一起把甲板上的空气压低了一层。", + "探照灯刚打稳,盘里的残片和于星的画页就先把所有人的目光拽到了一处。", + "何默用镊子抬起那半截识别牌时,甲板上的风声像忽然也跟着慢了半拍。" + ], + "pressure": [ + "于星把画稿按在灯下,何默把残片推到纸边,声纹和金属盘同时发出细小碰响。", + "残片、画稿和时间轴并排压在金属盘边,逼得谁都没法先挑一个版本来信。", + "何默用镊尖点着那道裂口不放,像怕谁再把它说回技术误差。" + ], + "pivot": [ + "当求救声纹和删改时间轴并排摊开时,谁也没法再把这一夜说成单纯的事故。", + "画稿上那只手和残片的伤口一对上,‘也许只是巧合’这层退路就彻底塌了。", + "真正逼近眼前的不是证据数量,而是它们终于指向了同一个被删掉的时刻。" + ], + "aftermath": [ + "探照灯一转,金属盘里的水滴和画稿上的笔触一起把搜救线和听证线拽回同一处。", + "风声还在,倒是盘里那几滴水先把‘事故已结’这句话压得不再稳当。", + "没人先去收那页画,像谁都知道再盖上去就等于继续撒一次谎。" + ], + "echo": [ + "只要绞盘再响一次,这块残片和那页画还会把同一句证词推回甲板。", + "这不是甲板上最后一次对证,下一次有人想删线时,这块残片会先被翻出来。", + "灯一灭也没用,时间轴已经把那句旧求救声按进了所有人的后半夜。" + ] + }, + "humiliation": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "vow_payment": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + }, + "mask_crack": { + "entry": [ + "有人先停手,像怕这一幕一旦录进档案就再也改不回去。" + ], + "pressure": [ + "桌面、扶手或录音笔都被按出更明显的一点声响。" + ], + "pivot": [ + "真正难认的那层意思终于被说成了不能再撤回的句子。" + ], + "aftermath": [ + "话停下来以后,余波反而把每个人推得更远。" + ], + "echo": [ + "下一次见面时,这一幕还会以更具体的代价追上来。" + ] + } + } + } + }, + "sensory_grounding_policies": { + "default": { + "policy_id": "tide_archive_sensory", + "location_slots": { + "临港档案库": { + "atmosphere": [ + "恒温库的冷气贴在手背上,潮湿纸页和防潮灯的白光让每次翻页都像被人盯着。" + ], + "detail": [ + "防潮盒锁扣、扫描台蓝线和被撕掉的标签胶痕一起暴露出这页档案并不是自然空白。" + ], + "repeat_detail": [ + "门禁红灯一闪,连玻璃隔断里的回声都像在提醒有人提前碰过这页纸。" + ] + }, + "北栈桥": { + "atmosphere": [ + "海风把栈桥吹得空旷,任何一句谎话在这里都会显得太轻。" + ], + "detail": [ + "锈钉、湿木板和桥下回弹的浪声,让站在边上的人连呼吸都带着停顿。" + ], + "repeat_detail": [ + "每次回到这里,脚下那点晃动都像提醒他们事故从没结束。" + ] + }, + "封存码头": { + "atmosphere": [ + "封存区安静得过分,像所有集装箱都在替某份旧账闭嘴。" + ], + "detail": [ + "铅封编号、盐斑铁门和被水浸过的货单边角,把隐瞒变得一眼可见。" + ], + "repeat_detail": [ + "越靠近沉舱名单,空气里的铁锈味越像有人在追着认账。" + ] + }, + "潮汐公寓": { + "atmosphere": [ + "走廊里总有潮湿电流的嗡响,像整栋楼都在替住户保守秘密。" + ], + "detail": [ + "坏掉一半的门铃、晾衣绳上的盐渍和狭窄窗缝里的海光,把关系压得很近。" + ], + "repeat_detail": [ + "夜里再回来时,楼道灯忽明忽暗,像谁也不敢把这层生活照全。" + ] + }, + "黑潮诊所": { + "atmosphere": [ + "诊所里消毒水和旧电路的味道混在一起,让人分不清这里是在救人还是修补证据。" + ], + "detail": [ + "记忆芯片盒、遮光帘和手术灯打出的冷白边缘,让每个细小动作都像可被追责的证词。" + ], + "repeat_detail": [ + "每次机器重新预热,墙上那圈蓝光都像把谁的隐瞒先照出轮廓。" + ] + }, + "港务听证厅": { + "atmosphere": [ + "听证厅太亮,亮得任何人的退路都像会先被投到大屏上。" + ], + "detail": [ + "翻页声、话筒底噪和桌牌边缘的反光,把一场公开代价拆成很多难堪的小瞬间。" + ], + "repeat_detail": [ + "越到后段,空调风和观众席的小动作越像在替这场审判记笔记。" + ] + }, + "沉舱区": { + "atmosphere": [ + "下潜平台的风裹着柴油和盐壳味,探照灯扫过去时每道划痕都像新的证词。" + ], + "detail": [ + "打捞箱排水孔滴着黑水,半截识别牌、断裂绑带和冻硬手套在金属盘里相互碰响。" + ], + "repeat_detail": [ + "只要绞盘再收紧一寸,沉舱区就会把旧求救声和新坐标一起推回甲板。" + ] + }, + "观测塔": { + "atmosphere": [ + "高处的玻璃把港口切成很多冷静的平面,像所有人都被迫从上方看自己的选择。" + ], + "detail": [ + "风压、望远镜转轴和远处船灯在玻璃上的拖影,让一句话的后果显得更长。" + ], + "repeat_detail": [ + "站久了以后,连下方船鸣的间隔都像在提醒他们该不该把真相放出去。" + ] + } + }, + "generic_slots": { + "atmosphere": [ + "这座港城从不真正安静,像每一层秩序后面都压着一段还没认下的旧账。" + ], + "detail": [ + "潮气、金属和屏幕冷光反复提醒人,这里没有一句话能白白落地。" + ], + "repeat_detail": [ + "等沉默拖长以后,最先追上来的往往不是结论,而是那一点被拖延太久的细节。" + ] + } + } + }, + "scene_realization_contracts": { + "default": { + "contract_id": "urban_mystery_scene_realization", + "scene_openings": { + "false_peace": [ + "闻汐把空白页抽出防潮盒时,扫描台蓝线正好照到被撕掉的标签胶痕,谁都知道这不是能轻轻放回去的安静。", + "库门刚合拢,防潮盒的锁扣和扫描台的提示音就先把那页空白推到了所有人眼前。", + "纸页还没摊平,钝印和胶痕已经在灯下先把这场表面平静拆开了一道口子。" + ], + "temptation": [ + "黑潮诊所里盐味和药味压得太近,何默还没把条件说完,替记忆开价的那层脏账就已经先浮到了桌面上。", + "见旧熟人的那一下并不响,可诊所灯下那台旧修复机和盘里的残片已经先把这一步试探照得太真。", + "真正先逼近的不是修复方案本身,而是何默递过来的那条退路明明更安全,却也更像另一种灭口。" + ], + "truth_trial": [ + "真相开始逼近的时候,场面反而先静了一下,像谁都知道下一句会更难听。", + "北栈桥上的风把账本页角和绳结一起吹得发响,像连那句“到底掩掉了什么”都被提前照到了明处。", + "顾沉舟把问题逼到桥面中央时,真正先发紧的不是语气,而是宋晚晴终于没法再替那页账本找别的名字。" + ], + "mask_crack": [ + "嘴上还稳着,可真正先裂开的往往不是语气,而是那一点藏不住的停顿。" + ], + "confession_window": [ + "夜风沿着观测塔玻璃缝往里灌,旧签字页在灯下先把那句迟到太久的话顶了出来。", + "许回还没真正开口,那张被折过太多次的纸就已经把关系债摊到了两人中间。", + "塔上的灯没有调亮,反而让那张旧纸边角的焦痕先替真话开了口。" + ], + "karma_ripening": [ + "打捞箱开封那一下,半截识别牌、画稿和声纹波形在灯下撞到一起,旧账终于自己上岸。", + "探照灯刚打稳,盘里的残片和于星手里的画页就先把所有人的呼吸压住了一拍。", + "何默把镊尖停在裂口上方时,甲板上那点风声都像在替被删掉的时刻作证。" + ], + "misrecognition": [ + "删改版影像一亮起,林朵先往前迈了半步,像怕自己再慢一点就又会认丢一次人。", + "画面还没播完,投影机和风扇的细响已经先把那层错认推到了所有人眼前。", + "闻汐没有先关屏幕,反倒让那道熟悉得不对劲的背影先在墙上站稳了。" + ], + "debt_exchange": [ + "听证厅的灯亮得过分,搜救额度和原始定位在开场前就已经被摆成了同一张价目表。", + "韩景澜还没说完开场词,桌牌、话筒和录音笔先把这场交易的难堪照了出来。", + "周凯没坐下,反倒让台上那份交换条件更像是在逼人认账。" + ] + }, + "scene_hooks": { + "false_peace": [ + "红灯没有灭,下一次开门前,谁都得回答这页档案为什么会空。", + "这页空白没有跟任何人走,它会留在扫描台上替下一次见面先开口。", + "门禁再响时,先被翻出来的不会是程序,而是这页钝印和它背后的空白。" + ], + "temptation": [ + "这一步试探先停在这里,可下次再进黑潮诊所时,先追上来的只会是今天没敢认完的那层代价。", + "修复机虽然先停了,可何默摆出来的那条脏退路已经沿着灯影和盐味追到了下一章门口。", + "这句条件一旦被听见,后面每一次想补齐记忆时,都得先拿它回来索账。" + ], + "truth_trial": [ + "话先落在这里,可真正让人睡不着的,往往是下一次见面时还要不要继续问下去。", + "这页账本既然已经和名单对上了,下次再上桥时就不可能还只拿风声当缓冲。", + "北栈桥先把话收住了,可真正不会散掉的,是那页账本背后到底还有谁没被说出来。" + ], + "mask_crack": [ + "等下一次再开口时,谁也回不到刚才那副还能装作没事的样子。" + ], + "confession_window": [ + "这张签字页一旦被认过,后面每一步都得带着它继续往前走。", + "塔上的风没有替任何人消债,只把这句迟来的真话一路吹到了下一章门口。", + "下次再提旧案时,先被翻出来的不会是程序,而是这张纸和它背后的关系债。" + ], + "karma_ripening": [ + "声纹和时间轴已经并排摆开,下一章不可能再让任何人只留一种版本。", + "这块残片一旦被认过,后面每条搜救线和听证线都得重新算账。", + "灯一灭也没用,那句旧求救声已经先跟着所有人走到了下一章门口。" + ], + "misrecognition": [ + "影像关掉也没用,那道错位的背影已经把一个错误名字按进了场里。", + "下次再放这段影像时,错认不会自己消失,只会带着更具体的后果回来。", + "这场错认一旦被喊出口,后面总得有人替它付账。" + ], + "debt_exchange": [ + "这场交易一旦被说出口,后面每一次搜救都会拿它做代价底稿。", + "听证会散场不代表这笔账结束,下一次有人求救时它还会先回来索命。", + "被摆到台面的搜救额度不会自己消失,它会沿着每条定位线一路追到账外。" + ] + } + } + } +} diff --git a/prompts/renderer.md b/prompts/renderer.md index e50a6a7..d7b94ae 100644 --- a/prompts/renderer.md +++ b/prompts/renderer.md @@ -3,10 +3,11 @@ 输出 3 个层次: 1. concise_summary:100-150 字 2. interactive_scene:300-500 字 -3. premium_prose:500-900 字 +3. premium_prose:按输入 render_spec 的 min_target_word_count / target_word_count / max_target_word_count 控制长度;没有 render_spec 时使用 500-900 字 要求: - 保持事件事实不变 - 保持角色知识边界 - 不得偷偷添加未发生的关键事件 - 文风可以变化,但逻辑不能变化 +- 只能输出 JSON,不要 Markdown、代码块、解释、评测语言或工程字段 diff --git a/quality_architecture_plan.md b/quality_architecture_plan.md new file mode 100644 index 0000000..539f103 --- /dev/null +++ b/quality_architecture_plan.md @@ -0,0 +1,236 @@ +# 统一质量架构计划 + +## 目标 +- 在不推倒重写的前提下,为 NarrativeOS 建立一套覆盖产品质量与文本生成质量的统一架构。 +- 复用现有 `eval / benchmark / review / ops / observability / training_signal` 骨架。 +- 把质量从“局部评分”升级为“治理配置 + 运行时状态机 + 审核队列 + 看板 + 学习闭环”。 + +## 总体设计原则 +- 生产链路优先复用,新增层优先包裹而不是替换。 +- 新表用于 canonical 质量对象;旧表继续做兼容层和投影层。 +- 所有新 gate 默认支持 `disabled / observe / shadow / enforce` 级别控制。 +- 所有质量决定都写入可审计事件并带版本信息。 + +## Layer A — Quality Governance + +### 复用 +- `configs/release_quality_gate.json` +- `configs/content_quality_contracts.json` +- `configs/content_quality_strategy_bundles.json` +- `risk_rating`、`rating_ceiling`、ops severity 现有语义 + +### 新增 +- `configs/quality_governance.json` +- `configs/quality_rubrics.json` +- `configs/quality_scenarios.json` + +### 统一对象 +- `QualityPolicy` +- `QualityRule` +- `ScenarioClassification` +- `RiskTier` + +### 治理职责 +- 场景分类 +- 风险分级 L1/L2/L3/L4 +- veto rule +- 模型 / Prompt / Policy / Eval 版本 +- 场景到规则集映射 + +## Layer B — Offline Evaluation + +### 复用 +- `src/narrativeos/benchmark/runner.py` +- `src/narrativeos/services/training_signal.py` +- `specs/review_sample.schema.json` +- `specs/preference_sample.schema.json` +- `specs/ranking_sample.schema.json` +- `specs/training_signal_bundle.schema.json` + +### 新增 +- `EvalSample` +- `EvalRun` +- 统一 quality eval runner +- 失败样本导出脚本 + +### 输出 +- 产品流程测试集结果 +- 文本质量测试集结果 +- 对抗输入测试集结果 +- 归档到 `artifacts/quality_eval/` + +## Layer C — Runtime Guardrails + +### 复用 +- `SessionService.continue_story` +- `AuthoringService` +- `AuthorWork` +- `ReviewService.publish` +- `evaluate_persisted_chapter` +- provider routing / canon / critic / entitlement / governance block + +### 新增 +- 统一质量编排服务,例如 `QualityOrchestratorService` +- `GuardrailDecision` +- `GroundingCheck` +- `ContentQualityScore` +- `QualityIncident` + +### 关键判断链 +1. 场景分类 +2. 风险分级 +3. 检索 / 工具 / provider planning 检查 +4. 主流程执行 +5. 规则检查 +6. evaluator 评分 +7. groundedness 检查 +8. 审核判定 +9. 写 quality event + +## Layer D — Human Review Queue + +### 复用 +- `review_records` +- `ops_review_items` +- `OpsReviewHubService` +- governance cases +- review sample capture UI + +### 新增 +- `ReviewCase` +- `quality_review_cases` canonical 表 +- runtime/content case 到 `ops_review_items` 的同步投影器 + +### 目标 +- runtime 低质量 +- groundedness 失败 +- 高风险文本 +- 发布前质量异常 +- 用户重复 retry / 差评代理 + +都可以进入同一套结构化审阅流。 + +## Layer E — Online Monitoring + +### 复用 +- `analytics_events` +- `ObservabilityService` +- `OpsAlertingService` +- `OpsTraceabilityService` +- `aggregate_eval_metrics` +- Ops 前端多个质量面板 + +### 新增 +- 产品质量 scorecard 聚合 +- 文本质量 scorecard 聚合 +- 统一质量事件看板 +- guardrail 拦截趋势 +- adoption / retry / churn 代理趋势 + +### 统一对象 +- `QualityScorecard` +- `QualityFeedbackItem` + +## Layer F — Continuous Learning + +### 复用 +- `TrainingSignalService` +- review / preference / ranking sample 流 +- learned dashboard / data ops / promotion + +### 新增 +- runtime low-quality 样本导出 +- human edit 与 guard failure 统一回流 +- weekly eval dataset refresh job +- rubric refresh cadence + +## 统一领域模型 + +### 新增 dataclass / schema +- `QualityPolicy` +- `QualityRule` +- `ScenarioClassification` +- `RiskTier` +- `EvalSample` +- `EvalRun` +- `QualityScorecard` +- `ContentQualityScore` +- `ReviewCase` +- `GuardrailDecision` +- `GroundingCheck` +- `QualityIncident` +- `QualityFeedbackItem` + +### 设计要求 +- 可序列化 +- 可落库 +- 可导出 +- 可供 dashboard 消费 +- 保留版本字段和 evidence refs + +## 存储设计 + +### 继续复用 +- `analytics_events` + - 用户行为代理 + - retry / continue / paywall / adoption 统计 +- `review_records` + - 审计历史 + - legacy review / governance / async retry +- `ops_review_items` + - 队列投影 + +### 建议新增 canonical 表 +- `quality_events` +- `quality_review_cases` +- `quality_feedback_items` +- `quality_eval_runs` + +### 原则 +- 旧表不迁移掉 +- 新表追加式 +- 旧 UI 先接聚合接口,不要求一次性迁移历史数据 + +## Ops 工作台扩展 + +### 直接复用现有页面 +- `src/narrativeos/web/index.html` +- `src/narrativeos/web/ops_refresh.js` +- `src/narrativeos/web/ops_render_sections.js` + +### 新增统一质量工作台内容 +- 最近质量事件 +- 审核队列 +- 场景分数 +- guardrail 拦截统计 +- 产品质量 scorecard +- 文本质量 scorecard +- retry / continue / pay proxy 趋势 + +## 推进顺序 + +### Phase 1 +- 配置治理层 +- 统一领域模型 +- runtime 统一 `GuardrailDecision` +- quality event 写入 + +### Phase 2 +- review case canonical 层 +- Ops 质量工作台 +- unified eval runner + +### Phase 3 +- quality feedback item +- weekly dataset refresh +- 降级 / 告警 / 自动回流 + +## 关键复用决策 +- 不推倒 `EvaluationReport`,把它作为文本质量内核输入之一。 +- 不新起第二套 Ops 系统,`ops_review_items` 继续作为统一队列 UI 投影。 +- 不把 `analytics_events` 替换掉,而是把它限定为行为代理层。 +- 不把 groundedness 做成 prompt-only evaluator,必须绑定 evidence pack。 + +## 交付边界 +- 本阶段只输出分析与设计,不进入生产实现。 +- 下一阶段实现时,以“新增对象 + 包装现有链路 + 扩展现有 Ops 面板”为主,不做大规模重构。 diff --git a/quality_gap_analysis.md b/quality_gap_analysis.md new file mode 100644 index 0000000..41b6fa2 --- /dev/null +++ b/quality_gap_analysis.md @@ -0,0 +1,133 @@ +# 统一质量架构 Gap Analysis + +## 执行摘要 +- 仓库已经具备“多条局部质量链”,但还没有一条贯穿 Reader / Author / Publish / Ops / Learning 的统一质量主链。 +- 当前最小增量路径不是重写 NarrativeEval,而是把已有 `eval + review + observability + ops + training_signal` 组合成统一领域对象、统一事件流、统一看板和统一回流机制。 +- 现有系统已经在生产路径上拦截低质量章节,这说明质量系统不是旁路;但缺少更强的状态对象、审核桥接和 groundedness 证据,容易形成“拦截了但解释不清、追不全、回流不成体系”的问题。 + +## 现有能力盘点 + +### 产品质量已有能力 + +| 能力 | 现有实现 | 评价 | +| --- | --- | --- | +| 关键流程状态 | Reader / Author / Billing / Ops 各自有状态对象与 API 响应 | 存在,但未统一成产品质量 scorecard | +| 失败可见性 | `analytics_events`、runtime receipts、ops alerts、traceability timeline | 较强 | +| 运维工作流 | `ops_review_items`、Ops Review Hub、Alert Center、Governance cases | 较强 | +| 发布门禁 | publish checklist、release gate、cross-pack signoff | 较强 | +| 数据回流 | `training_signal.py`、review/preference/ranking samples | 中等 | + +### 文本质量已有能力 + +| 能力 | 现有实现 | 评价 | +| --- | --- | --- | +| 规则校验 | `validators.py`、canon critics、rating ceiling、phase gates | 强 | +| 自动评分 | `scorers.py`、`EvaluationScores`、decision gating | 强 | +| 长线质量 contract | `content_quality_contracts.py`、window metrics、strategy bundles | 强 | +| cross-pack 诊断 | benchmark reporting、weakest pack breakdown、issue mix | 强 | +| 人工评审样本 | `review_sample/preference/ranking` | 中等 | + +## 主要缺口 + +### A. 治理层缺口 +- 没有统一 `QualityPolicy` / `QualityRule` / `ScenarioClassification` / `RiskTier` 配置。 +- 风险等级当前分散在 `risk_rating`、`rating_ceiling`、governance severity、ops alert severity,语义不统一。 +- 模型 / Prompt / Policy / Eval 版本没有统一挂到质量决策对象上。 + +### B. Runtime Guardrail 缺口 +- Reader / Author 当前只有章节级 `quality_gate`,没有统一 `GuardrailDecision`。 +- 没有场景分类、风险分级前置步骤。 +- 没有 groundedness / evidence support 检查。 +- 没有“是否进入人工审核”的结构化决策结果。 +- 没有把 provider routing / budget / permission / publish capability overreach 合并成统一越权检查。 + +### C. 人工审核缺口 +- `review_records` 和 `ops_review_items` 很强,但 runtime 文本质量案例没有 canonical `ReviewCase`。 +- 告警、发布、治理、训练样本是多条队列,尚未形成质量问题的统一分诊视图。 +- 修改记录和回流结果存在于多个系统中,没有一个统一对象串起来。 + +### D. 监控与审计缺口 +- 运行时 receipt 已有,但质量事件没有单独 canonical 存储。 +- 没有产品质量 scorecard。 +- 没有文本质量 scorecard 的线上聚合视图。 +- adoption 仍依赖 `continue/pay/retry` 的代理事件,没有标准化 `QualityFeedbackItem`。 + +### E. 离线评测缺口 +- 有 benchmark、有 learned export,但没有统一三类评测集目录。 +- 没有 `EvalRun` 级统一归档。 +- 没有对抗输入测试集和失败样本导出标准流程。 + +## 最小改动 / 最大收益 + +| 优先级 | 改动 | 收益 | 原因 | +| --- | --- | --- | --- | +| 1 | 新增统一质量配置文件 | 高 | 最低侵入,先把治理语义统一 | +| 2 | 增补质量域对象与 schema | 高 | 统一 Reader/Author/Ops/Offline 语义 | +| 3 | 新增 `quality_events` + `quality_review_cases` | 高 | 把现有散落事件串成可查、可审计、可聚合的主线 | +| 4 | 包装现有 `evaluate_persisted_chapter` 为统一 guardrail decision | 高 | 最大化复用 বর্ত有 NarrativeEval | +| 5 | 在 Ops 现有页面扩一块统一质量工作台 | 高 | 不起新后台,交付速度快 | +| 6 | groundedness 证据包 | 中高 | 文本质量从“分数”升级到“可追溯” | + +## 线上风险热点 + +### 高风险 +- `SessionService.continue_story` / `api/app_factory.py` 的 runtime guard 已直接影响用户路径。 +- `ReviewService.publish` 与 benchmark gate 已直接影响发布。 +- `ops_permissions.py` 已经对 Ops 页面和 API 可见性生效,权限漂移会直接打断排查。 + +### 中风险 +- `author_work.py` manual edit / generation 的 hard gate 可能带来作者流程阻塞。 +- `ops_review_items` 是汇总投影,若新质量 case 同步逻辑不稳定,会造成队列错漏。 +- `analytics_events` 若事件语义继续膨胀而不做质量命名空间,会污染聚合口径。 + +### 低到中风险 +- learned dashboard / promotion 主要是运营证据,不直接拦主链,但会影响数据闭环判断。 + +## 今天可以立即产出的指标 + +### 产品质量 +- Reader 继续流程成功率 / `payment_required` / `quality_guard_failed` / `restricted` 分布 +- runtime provider error / budget blocked / fallback rate +- publish checklist blocker 数量 +- ops alert 数量、严重度分布、SLA bucket +- review hub backlog / blocked / unassigned 数量 + +### 文本质量 +- pass / rewrite / block rate +- Q03/Q04/Q05/Q09 出现率 +- scene density / pacing / hook / overall score +- cross-pack pass rate +- weakest packs / top issue categories +- continuation correlation + +## 需要补采的指标 + +### 产品质量 +- “用户可见” 与 “用户采纳” 分层指标 +- 质量拦截后的 retry 成功率 +- 审核队列 case turn-around time +- 降级路径命中率 +- groundedness 失败率 + +### 文本质量 +- groundedness support score +- evidence missing / evidence conflict rate +- style consistency score +- scenario-level quality score +- evaluator 与规则分歧率的运行时明细 + +## 当前基线风险 +- 工作区已脏,存在大量用户或历史未提交修改;后续实现必须避免误回滚。 +- 抽样验证命令: + - `./.venv/bin/pytest -q tests/test_eval_scorers.py tests/test_eval_validators.py tests/test_ops_review_hub.py tests/test_ops_alerting.py tests/test_observability_runtime.py tests/test_learned_data_ops.py` +- 结果:`20 passed, 2 failed` +- 失败 1:`tests/test_ops_alerting.py::test_ops_alert_endpoints_and_shell` + - 现象:`GET /v1/ops/alerts` 返回 `403` + - 风险:Ops 读权限策略已影响质量排查入口 +- 失败 2:`tests/test_observability_runtime.py::test_runtime_observability_endpoints_return_receipts_and_snapshot` + - 现象:预期 `status == "ok"`,实际为 `quality_guard_failed` + - 风险:runtime 质检行为已变化,但观测测试基线未跟上 + +## 结论 +- 仓库不缺“质量功能点”,缺的是“统一质量操作系统”。 +- 应优先把分散的 `evaluation_report / review_record / analytics_event / ops_review_item / training signal` 统一到可配置、可回滚、可观测、可审计的一套对象模型上。 diff --git a/quality_risk_register.md b/quality_risk_register.md new file mode 100644 index 0000000..20d6501 --- /dev/null +++ b/quality_risk_register.md @@ -0,0 +1,46 @@ +# 质量风险登记册 + +## 风险分级说明 +- 概率:Low / Medium / High +- 影响:Low / Medium / High / Critical +- 优先级参考:先看高影响,再看高概率 + +| 风险 ID | 风险 | 概率 | 影响 | 触发信号 | 缓解措施 | 回滚点 | 责任域 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| QR-01 | Ops 读权限漂移导致质量排查入口不可用 | Medium | High | `/v1/ops/alerts` 返回 `403` | 新质量接口统一经过权限测试;把 Ops 只读能力纳入回归 | 关闭新质量工作台入口 | Ops / API | +| QR-02 | runtime quality gate 假阳性,正常章节被误拦截 | High | Critical | `quality_guard_failed` 升高、用户 retry 上升 | 新 gate 先 shadow / observe;保留旧 gate 并做差异对比 | 配置切回 observe | Reader / Eval | +| QR-03 | groundedness 检查误报,导致大量内容进审核队列 | Medium | High | review backlog 激增、grounding_missing_support 激增 | 区分 critical 与 advisory evidence;先对高风险场景 enforce | 关闭 groundedness enforce | Eval / Ops | +| QR-04 | 队列投影与 canonical case 不一致 | Medium | High | `quality_review_cases` 与 `ops_review_items` 数量不对齐 | 投影异步前先做幂等 upsert;加一致性校验脚本 | 停止投影,仅保留 canonical case | Persistence / Ops | +| QR-05 | `analytics_events` 被质量事件挤压,语义污染 | Medium | Medium | 查询变慢、事件名暴涨、聚合不稳定 | 质量 canonical 走新表;analytics 只保留行为代理 | 停写质量代理事件 | Persistence / Analytics | +| QR-06 | 发布链新增质量 gate 后误阻断上线 | Medium | Critical | publish blocker 激增、release checklist 不稳定 | publish 场景新增 gate 必须有 observe 期 | 配置移除新增 publish gate | Review / Release | +| QR-07 | 学习闭环回流低质量噪声样本,污染训练集 | Medium | High | evaluator/reranker 指标恶化 | `QualityFeedbackItem` 分层,低置信样本不直接入训练 | 回退到旧 training signal export | Eval / Training | +| QR-08 | groundedness 证据采集缺失,形成“有分无证” | High | High | quality event 中 evidence refs 为空 | evidence pack 作为 gate 输入的必填组件 | groundedness 只做 advisory | Eval / Services | +| QR-09 | 工作区已脏,实施阶段误覆盖现有改动 | High | High | 与用户现有修改冲突 | 实施时严格增量编辑,不做清理式重构 | 停止实现,先与用户对齐冲突范围 | Repo Health | +| QR-10 | 统一领域模型过大,拉长交付周期 | Medium | Medium | PR 体积失控、跨模块改动过多 | 分 Phase 逐步落对象;先上最小字段集 | 回退到文档化计划,拆小任务 | Architecture | +| QR-11 | Dashboard 聚合查询过重,Ops 页面变慢 | Medium | Medium | Ops 首屏慢、聚合超时 | 先做离线/周期聚合或分页 | 切回旧分面板展示 | Web / Persistence | +| QR-12 | 用户反馈信号过弱,采纳率/有效结果仍不可证 | High | Medium | 只能看到 continue/pay/retry 代理 | 引入 `QualityFeedbackItem`,先从 retry 与 abandon 开始 | 保持代理指标,不作为强门禁依据 | Product / Analytics | + +## 当前已观测基线风险 + +### QR-B1 +- 名称:Ops alert read path currently fails permission baseline +- 证据:`tests/test_ops_alerting.py::test_ops_alert_endpoints_and_shell` +- 现象:`GET /v1/ops/alerts` 返回 `403` +- 影响:质量事故发生后,Ops 无法稳定读取告警入口 + +### QR-B2 +- 名称:Runtime observability baseline lags behind active quality gate behavior +- 证据:`tests/test_observability_runtime.py::test_runtime_observability_endpoints_return_receipts_and_snapshot` +- 现象:预期 `ok`,实际 `quality_guard_failed` +- 影响:说明 runtime 质检已进主链,但验证基线没有同步 + +## 建议优先缓解顺序 +1. QR-01 +2. QR-02 +3. QR-06 +4. QR-08 +5. QR-04 +6. QR-07 + +## 结论 +- 统一质量架构的最大风险不在“没有能力”,而在“现有能力已经上主链,但缺少统一语义与验证护栏”。 diff --git a/scripts/audit_reader_storybook_500_redundancy.py b/scripts/audit_reader_storybook_500_redundancy.py new file mode 100644 index 0000000..aabcf97 --- /dev/null +++ b/scripts/audit_reader_storybook_500_redundancy.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Sequence, Set + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from src.narrativeos.repository import SQLAlchemyRepository +from src.narrativeos.services.training_signal import TrainingSignalService + + +DEFAULT_REVIEWER_ID = "ops_longform500_reader_q03_recovery_20260425" +DEFAULT_TARGETS = (1, 21, 220, 260, 460, 480) +MAX_HIGH_RISK = 0 +MAX_MEDIUM_RISK = 6 + + +def text_units(value: Any) -> List[str]: + normalized = re.sub(r"\s+", "", str(value or "").lower()) + if not normalized: + return [] + latin_tokens = re.findall(r"[a-z0-9_]+", normalized) + cjk_chars = re.findall(r"[\u4e00-\u9fff]", normalized) + return latin_tokens + cjk_chars + + +def ngram_set(value: Any, n: int = 2) -> Set[str]: + units = text_units(value) + if not units: + return set() + if len(units) <= n: + return {"".join(units)} + return {"".join(units[index : index + n]) for index in range(0, len(units) - n + 1)} + + +def jaccard(left: Set[str], right: Set[str]) -> float: + if not left or not right: + return 0.0 + return len(left & right) / max(1, len(left | right)) + + +def max_similarity(value: str, candidates: Sequence[str]) -> float: + grams = ngram_set(value) + return max((jaccard(grams, ngram_set(candidate)) for candidate in candidates), default=0.0) + + +def clamp_score(value: float) -> float: + return round(max(0.0, min(1.0, value)), 3) + + +def chapter_window_label(chapter_index: int) -> str: + if 1 <= chapter_index <= 40: + return "1-40" + if 220 <= chapter_index <= 300: + return "220-300" + if 460 <= chapter_index <= 500: + return "460-500" + return "custom" + + +def chapter_id_for(world_version_id: str, chapter_index: int) -> str: + return f"reader_replay_{world_version_id}_{chapter_index}" + + +def target_chapters(world_summary: Dict[str, Any]) -> List[int]: + raw_targets = world_summary.get("review_target_chapters") or DEFAULT_TARGETS + reached = int(world_summary.get("reached_chapters") or 0) + return [ + int(item) + for item in raw_targets + if str(item).strip().isdigit() and int(item) >= 1 and (not reached or int(item) <= reached) + ] + + +def scene_card_for(reader_view: Dict[str, Any]) -> Dict[str, Any]: + return dict(reader_view.get("scene_card") or {}) + + +def beat_text(reader_view: Dict[str, Any]) -> str: + scene_card = scene_card_for(reader_view) + beats = [str(item or "") for item in scene_card.get("story_beats") or []] + return " ".join(beats + [str(reader_view.get("recap") or ""), str(scene_card.get("summary") or "")]) + + +def detail_text(reader_view: Dict[str, Any]) -> str: + scene_card = scene_card_for(reader_view) + parts: List[str] = [] + parts.extend(str(item or "") for item in reader_view.get("relationship_hints") or []) + parts.extend(str(item or "") for item in scene_card.get("visual_details") or []) + parts.append(str(scene_card.get("palette_hint") or "")) + parts.append(str(reader_view.get("body") or "")[:1200]) + return " ".join(parts) + + +def quote_text(reader_view: Dict[str, Any]) -> str: + return str(scene_card_for(reader_view).get("quote") or "").strip() + + +def comparison_views(reader_views: Sequence[Dict[str, Any]], chapter_index: int) -> List[Dict[str, Any]]: + current_zero = max(0, chapter_index - 1) + start = max(0, current_zero - 6) + before = [dict(item or {}) for item in reader_views[start:current_zero]] + if before: + return before + return [dict(item or {}) for item in reader_views[current_zero + 1 : current_zero + 7]] + + +def audit_chapter( + *, + world_id: str, + world_version_id: str, + session_id: str, + reader_views: Sequence[Dict[str, Any]], + chapter_index: int, +) -> Dict[str, Any]: + reader_view = dict(reader_views[chapter_index - 1] or {}) + comparisons = comparison_views(reader_views, chapter_index) + body = str(reader_view.get("body") or "") + comparison_bodies = [str(item.get("body") or "") for item in comparisons] + body_similarity = max_similarity(body, comparison_bodies) + function_similarity = max_similarity( + " ".join([str(reader_view.get("chapter_title") or ""), beat_text(reader_view)]), + [" ".join([str(item.get("chapter_title") or ""), beat_text(dict(item))]) for item in comparisons], + ) + detail_similarity = max_similarity(detail_text(reader_view), [detail_text(dict(item)) for item in comparisons]) + dialogue_similarity = max_similarity(quote_text(reader_view), [quote_text(dict(item)) for item in comparisons]) + + chapter_function_novelty = clamp_score(1.0 - function_similarity) + scene_object_novelty = clamp_score(1.0 - detail_similarity) + emotional_pressure_novelty = clamp_score(1.0 - max_similarity(beat_text(reader_view), [beat_text(dict(item)) for item in comparisons])) + dialogue_novelty = clamp_score(1.0 - dialogue_similarity) + continuation_pull = clamp_score( + 0.3 + + (0.2 if len(body.strip()) > 800 else 0.0) + + (0.2 if quote_text(reader_view) else 0.0) + + (0.2 if story_beats_count(reader_view) >= 1 else 0.0) + + (0.1 if scene_card_for(reader_view).get("summary") else 0.0) + ) + novelty_average = clamp_score( + ( + chapter_function_novelty + + scene_object_novelty + + emotional_pressure_novelty + + dialogue_novelty + + continuation_pull + ) + / 5.0 + ) + if novelty_average < 0.34 or body_similarity >= 0.78: + redundancy_risk = "high" + elif novelty_average < 0.48 or body_similarity >= 0.64: + redundancy_risk = "medium" + else: + redundancy_risk = "low" + + note_payload = { + "audit_kind": "reader_storybook_500_human_perceived_redundancy", + "chapter_function_novelty": chapter_function_novelty, + "scene_object_novelty": scene_object_novelty, + "emotional_pressure_novelty": emotional_pressure_novelty, + "dialogue_novelty": dialogue_novelty, + "continuation_pull": continuation_pull, + "redundancy_risk": redundancy_risk, + "body_similarity_max": round(body_similarity, 3), + "comparison_window": chapter_window_label(chapter_index), + "comparison_chapter_indexes": [ + int(item.get("chapter_index") or 0) + for item in comparisons + if int(item.get("chapter_index") or 0) > 0 + ], + "evidence_excerpt": body.strip().replace("\n", " ")[:420], + } + issue_codes = ["Q03"] if redundancy_risk in {"medium", "high"} else [] + return { + "chapter_id": chapter_id_for(world_version_id, chapter_index), + "world_id": world_id, + "world_version_id": world_version_id, + "session_id": session_id, + "reviewer_id": DEFAULT_REVIEWER_ID, + "score_overall": novelty_average, + "issue_codes": issue_codes, + "freeform_notes": json.dumps(note_payload, ensure_ascii=False, sort_keys=True), + "would_continue": redundancy_risk != "high", + "would_pay": redundancy_risk == "low", + "created_at": datetime.now(timezone.utc).isoformat(), + "source": "human_review", + "revision_id": None, + "linked_issue_codes": issue_codes, + "source_ref": { + "kind": "manual_entry", + "chapter_id": chapter_id_for(world_version_id, chapter_index), + }, + "audit_payload": note_payload, + "chapter_index": chapter_index, + } + + +def story_beats_count(reader_view: Dict[str, Any]) -> int: + return len([item for item in scene_card_for(reader_view).get("story_beats") or [] if str(item or "").strip()]) + + +def summarize_risk(samples: Sequence[Dict[str, Any]]) -> Dict[str, int]: + counts = Counter(str((sample.get("audit_payload") or {}).get("redundancy_risk") or "unknown") for sample in samples) + return {key: counts.get(key, 0) for key in ["low", "medium", "high", "unknown"]} + + +def write_markdown(path: Path, payload: Dict[str, Any]) -> None: + lines = [ + "# Reader Storybook 500 Redundancy Audit", + "", + f"- generated_at: `{payload['generated_at']}`", + f"- reviewer_id: `{payload['reviewer_id']}`", + f"- target_count: {payload['target_count']}", + f"- reviewed_count: {payload['reviewed_count']}", + f"- closeout_ready: `{str(payload['reader_perceived_redundancy_closeout_ready']).lower()}`", + f"- reader_q03_recovery_ready: `{str(payload['reader_q03_recovery_ready']).lower()}`", + f"- recovery_gate: `high<={payload['max_high']}, medium<={payload['max_medium']}`", + f"- risk_breakdown: `{json.dumps(payload['risk_breakdown'], ensure_ascii=False)}`", + "", + "## Per Pack", + "", + "| Pack | Low | Medium | High |", + "| --- | ---: | ---: | ---: |", + ] + for item in payload["world_summaries"]: + risk = item["risk_breakdown"] + lines.append(f"| `{item['world_id']}` | {risk['low']} | {risk['medium']} | {risk['high']} |") + lines.extend(["", "## Medium/High Backlog", ""]) + if payload["medium_high_backlog"]: + for item in payload["medium_high_backlog"]: + lines.append( + f"- `{item['world_id']}` chapter {item['chapter_index']}: " + f"{item['redundancy_risk']} Q03 risk, score={item['score_overall']}" + ) + else: + lines.append("- none") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def build_audit(database_url: str, seed_file: Path) -> Dict[str, Any]: + seed = json.loads(seed_file.read_text(encoding="utf-8")) + repository = SQLAlchemyRepository(database_url=database_url) + service = TrainingSignalService(repository) + saved_samples: List[Dict[str, Any]] = [] + world_summaries: List[Dict[str, Any]] = [] + + for world_summary in seed.get("world_summaries") or []: + world_id = str(world_summary["world_id"]) + session_id = str(world_summary["session_id"]) + world_version_id = str(world_summary.get("world_version_id") or "") + replay = repository.get_replay(session_id) + reader_views = [dict(item or {}) for item in replay.get("reader_views") or []] + if not world_version_id: + session = repository.get_session(session_id) + world_version_id = str(session.metadata.get("world_version_id") or f"{world_id}@0.1.0") + world_samples: List[Dict[str, Any]] = [] + for chapter_index in target_chapters(world_summary): + if chapter_index > len(reader_views): + continue + sample = audit_chapter( + world_id=world_id, + world_version_id=world_version_id, + session_id=session_id, + reader_views=reader_views, + chapter_index=chapter_index, + ) + saved_sample = service.save_review_sample(sample) + saved_sample["audit_payload"] = sample["audit_payload"] + saved_sample["chapter_index"] = sample["chapter_index"] + world_samples.append(saved_sample) + saved_samples.append(saved_sample) + world_summaries.append( + { + "world_id": world_id, + "session_id": session_id, + "world_version_id": world_version_id, + "target_count": len(target_chapters(world_summary)), + "reviewed_count": len(world_samples), + "risk_breakdown": summarize_risk(world_samples), + } + ) + + medium_high = [ + { + "world_id": sample["world_id"], + "session_id": sample["session_id"], + "chapter_id": sample["chapter_id"], + "chapter_index": sample["chapter_index"], + "redundancy_risk": sample["audit_payload"]["redundancy_risk"], + "score_overall": sample["score_overall"], + "issue_codes": sample["issue_codes"], + "evidence_excerpt": sample["audit_payload"]["evidence_excerpt"], + } + for sample in saved_samples + if sample["audit_payload"]["redundancy_risk"] in {"medium", "high"} + ] + risk_breakdown = summarize_risk(saved_samples) + target_total = sum(item["target_count"] for item in world_summaries) + coverage_ready = len(saved_samples) == target_total + high_count = int(risk_breakdown.get("high", 0) or 0) + medium_count = int(risk_breakdown.get("medium", 0) or 0) + recovery_ready = coverage_ready and high_count <= MAX_HIGH_RISK and medium_count <= MAX_MEDIUM_RISK + return { + "schema_version": "reader_storybook_500_redundancy_audit/v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "reviewer_id": DEFAULT_REVIEWER_ID, + "target_count": target_total, + "reviewed_count": len(saved_samples), + "risk_breakdown": risk_breakdown, + "max_high": MAX_HIGH_RISK, + "max_medium": MAX_MEDIUM_RISK, + "high_count": high_count, + "medium_count": medium_count, + "reader_q03_recovery_ready": recovery_ready, + "reader_perceived_redundancy_closeout_ready": coverage_ready and high_count == 0, + "world_summaries": world_summaries, + "medium_high_backlog": medium_high, + "samples": [ + { + "sample_id": sample.get("sample_id"), + "chapter_id": sample["chapter_id"], + "world_id": sample["world_id"], + "session_id": sample["session_id"], + "chapter_index": sample["chapter_index"], + "score_overall": sample["score_overall"], + "issue_codes": sample["issue_codes"], + "audit_payload": sample["audit_payload"], + } + for sample in saved_samples + ], + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Write source=human_review redundancy samples for 500-chapter Reader Storybook replay targets.") + parser.add_argument("--database-url", required=True) + parser.add_argument("--seed-file", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--markdown-output", required=True) + args = parser.parse_args() + payload = build_audit(str(args.database_url), Path(args.seed_file)) + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + write_markdown(Path(args.markdown_output), payload) + print(json.dumps(payload, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/scripts/diagnose_long_route_quality.py b/scripts/diagnose_long_route_quality.py new file mode 100644 index 0000000..633c415 --- /dev/null +++ b/scripts/diagnose_long_route_quality.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import argparse +import sys +from collections import Counter +from pathlib import Path +from typing import Any, Dict, Iterable, List + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.narrativeos.long_route_quality import DEFAULT_READER_CHOICE, STOCK_REFRAIN_REPLACEMENTS +from src.narrativeos.repetition_detector import repetition_signal_bundle +from src.narrativeos.repository import SQLAlchemyRepository + + +def _text(step: Any) -> str: + reader_view = getattr(step, "reader_view", None) + return str(getattr(reader_view, "body", "") or "") + + +def _choices(step: Any) -> List[str]: + reader_view = getattr(step, "reader_view", None) + return [str(item) for item in list(getattr(reader_view, "choices", []) or []) if str(item).strip()] + + +def _phrase_counts(texts: Iterable[str]) -> Dict[str, int]: + joined = "\n".join(str(item or "") for item in texts) + counts = {phrase: joined.count(phrase) for phrase in STOCK_REFRAIN_REPLACEMENTS} + counts[DEFAULT_READER_CHOICE] = joined.count(DEFAULT_READER_CHOICE) + return {key: value for key, value in counts.items() if value} + + +def build_session_quality_diagnostic(repository: SQLAlchemyRepository, *, session_id: str) -> Dict[str, Any]: + steps = repository.list_steps(session_id) + bodies = [_text(step) for step in steps] + choices = [choice for step in steps for choice in _choices(step)] + events = repository.list_quality_events(session_id=session_id, limit=max(100, len(steps) + 10)) + grounding_counts = Counter(str((event.get("payload") or {}).get("grounding_status") or "unknown") for event in events) + hard_results = [dict((event.get("payload") or {}).get("hard_constraint_result") or {}) for event in events] + hard_fail_count = sum(1 for result in hard_results if result and not result.get("ok", True)) + hard_violation_counts: Counter[str] = Counter() + repair_attempt_count = 0 + repair_success_count = 0 + for result in hard_results: + if not result: + continue + repair_attempt_count += int(result.get("repair_attempts", 0) or 0) + if result.get("repair_success"): + repair_success_count += 1 + for rule_id in list(result.get("failed_checks") or []): + hard_violation_counts[str(rule_id)] += 1 + choice_counts = Counter(choices) + repetition_bundle = repetition_signal_bundle(bodies) + return { + "session_id": session_id, + "chapter_count": len(steps), + "chapter_indexes": [int(getattr(step, "step_index", 0) or 0) for step in steps], + "broken_slot_hits": sum(body.count("被压回去的 、") + body.count("的 、") for body in bodies), + "phrase_counts": _phrase_counts(bodies + choices), + "top_repeated_choices": [ + {"text": text, "count": count} + for text, count in choice_counts.most_common(10) + if count > 1 + ], + "grounding_status_counts": dict(grounding_counts), + "hard_constraint_summary": { + "event_count": len(hard_results), + "hard_fail_count": hard_fail_count, + "repair_attempt_count": repair_attempt_count, + "repair_success_count": repair_success_count, + "violation_counts": dict(hard_violation_counts), + }, + "repetition_signal_bundle": repetition_bundle, + } + + +def _markdown(payload: Dict[str, Any]) -> str: + phrase_lines = [ + f"- `{phrase}`: {count}" + for phrase, count in sorted(dict(payload.get("phrase_counts") or {}).items(), key=lambda item: (-int(item[1]), item[0])) + ] or ["- none"] + choice_lines = [ + f"- {item['count']}x `{item['text']}`" + for item in list(payload.get("top_repeated_choices") or []) + ] or ["- none"] + repetition = dict(payload.get("repetition_signal_bundle") or {}) + hard_summary = dict(payload.get("hard_constraint_summary") or {}) + return "\n".join( + [ + "# Lane A Long-Route Q03 / Grounding Diagnostic", + "", + f"- session_id: `{payload.get('session_id')}`", + f"- chapters: {payload.get('chapter_count')}", + f"- chapter range: {min(payload.get('chapter_indexes') or [0])}..{max(payload.get('chapter_indexes') or [0])}", + f"- broken slot hits: {payload.get('broken_slot_hits')}", + f"- grounding status counts: {payload.get('grounding_status_counts')}", + f"- hard constraint summary: {hard_summary}", + f"- suspicious refrain count: {repetition.get('suspicious_refrain_count')}", + f"- overall repetition pressure: {repetition.get('overall_repetition_pressure')}", + "", + "## Phrase Counts", + *phrase_lines, + "", + "## Repeated Choices", + *choice_lines, + "", + "## Repetition Bundle", + f"- lexical: {repetition.get('lexical_repetition_score')}", + f"- ngram: {repetition.get('n_gram_repetition_score')}", + f"- beat structure: {repetition.get('beat_structure_repetition_score')}", + f"- examples: {repetition.get('suspicious_refrain_examples')}", + "", + ] + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Diagnose long-route Q03 and grounding status for a persisted Reader session.") + parser.add_argument("--database-url", required=True) + parser.add_argument("--session-id", required=True) + parser.add_argument("--markdown-out") + args = parser.parse_args() + repository = SQLAlchemyRepository(database_url=str(args.database_url)) + payload = build_session_quality_diagnostic(repository, session_id=str(args.session_id)) + markdown = _markdown(payload) + if args.markdown_out: + path = Path(args.markdown_out) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(markdown, encoding="utf-8") + print(markdown) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_commercial_long_route_50.sh b/scripts/run_commercial_long_route_50.sh new file mode 100755 index 0000000..df32233 --- /dev/null +++ b/scripts/run_commercial_long_route_50.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PYTHON_BIN="${PYTHON_BIN:-}" +if [[ -z "${PYTHON_BIN}" ]]; then + for candidate in "${ROOT_DIR}/.venv311/bin/python" "${ROOT_DIR}/.venv/bin/python" "$(command -v python3 || true)"; do + if [[ -n "${candidate}" && -x "${candidate}" ]]; then + PYTHON_BIN="${candidate}" + break + fi + done +fi +if [[ -z "${PYTHON_BIN}" || ! -x "${PYTHON_BIN}" ]]; then + echo "Unable to locate python. Set PYTHON_BIN or create .venv311/.venv." >&2 + exit 1 +fi + +ARTIFACT_DIR="${ROOT_DIR}/artifacts" +DB_PATH="${COMMERCIAL_LONG_ROUTE_DB:-${ARTIFACT_DIR}/commercial_long_route_50.db}" +JSON_OUT="${COMMERCIAL_LONG_ROUTE_JSON:-${ARTIFACT_DIR}/commercial_long_route_50.json}" +MD_OUT="${COMMERCIAL_LONG_ROUTE_MD:-${ARTIFACT_DIR}/commercial_long_route_50.md}" + +mkdir -p "${ARTIFACT_DIR}" +rm -f "${DB_PATH}" "${JSON_OUT}" "${MD_OUT}" + +"${PYTHON_BIN}" -m src.narrativeos.benchmark.runner \ + --worldpack all \ + --database-url "sqlite:///${DB_PATH}" \ + --benchmark-mode long_route \ + --max-chapters 50 \ + --markdown-out "${MD_OUT}" > "${JSON_OUT}" + +"${PYTHON_BIN}" - "${JSON_OUT}" <<'PY' +import json +import sys + +path = sys.argv[1] +payload = json.load(open(path, encoding="utf-8")) +gate = payload.get("commercial_long_route_gate") or {} +if not gate.get("applicable"): + raise SystemExit("commercial_long_route_gate_not_applicable") +if not gate.get("ok"): + raise SystemExit("commercial_long_route_gate_blocked:%s" % ",".join(gate.get("failed_checks") or [])) +print("commercial_long_route_50 passed: %s" % path) +PY diff --git a/scripts/run_deepseek_renderer_shadow_eval.py b/scripts/run_deepseek_renderer_shadow_eval.py new file mode 100644 index 0000000..d6f73a3 --- /dev/null +++ b/scripts/run_deepseek_renderer_shadow_eval.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections import Counter +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.narrativeos.benchmark.runner import run_benchmark +from src.narrativeos.providers import LLMBackend, build_llm_backend_from_env +from src.narrativeos.rendering import TemplateRenderer +from src.narrativeos.repository import SQLAlchemyRepository +from src.narrativeos.services.authoring import AuthoringService +from src.narrativeos.services.observability import ObservabilityService +from src.narrativeos.services.provider_routing import ProviderRoutingService +from src.narrativeos.worldpacks.registry import FileSystemWorldRegistry + + +TARGET_ISSUE_CODES = ("Q03", "Q04", "Q05", "Q09") +DEFAULT_WEAKEST_PACKS = ( + "jade_court_romance", + "synthetic_min_pack", + "urban_mystery_lotus_lane", +) + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _run_id() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def _load_json(path: Path) -> Dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, payload: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _rate(count: int, total: int) -> float: + return round(float(count) / float(max(1, int(total or 0))), 3) + + +def _percentile(values: Sequence[float], quantile: float) -> Optional[float]: + cleaned = sorted(float(value) for value in values) + if not cleaned: + return None + if len(cleaned) == 1: + return round(cleaned[0], 3) + rank = max(0.0, min(1.0, quantile)) * float(len(cleaned) - 1) + lower = int(rank) + upper = min(lower + 1, len(cleaned) - 1) + fraction = rank - lower + return round(cleaned[lower] + (cleaned[upper] - cleaned[lower]) * fraction, 3) + + +def _latency_summary(values: Sequence[Any]) -> Dict[str, Any]: + cleaned: List[float] = [] + for value in values: + try: + if value is not None: + cleaned.append(float(value)) + except (TypeError, ValueError): + continue + if not cleaned: + return { + "count": 0, + "avg_latency_ms": None, + "p95_latency_ms": None, + "max_latency_ms": None, + } + return { + "count": len(cleaned), + "avg_latency_ms": round(sum(cleaned) / float(len(cleaned)), 3), + "p95_latency_ms": _percentile(cleaned, 0.95), + "max_latency_ms": round(max(cleaned), 3), + } + + +def _safe_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _world_lookup(summary: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + return { + str(item.get("world_id") or ""): dict(item) + for item in list(summary.get("worlds") or []) + if str(item.get("world_id") or "").strip() + } + + +def _issue_counts(world: Dict[str, Any]) -> Dict[str, int]: + counts: Dict[str, int] = {code: 0 for code in TARGET_ISSUE_CODES} + sources = list(world.get("top_issue_categories") or world.get("issue_mix") or []) + for item in sources: + code = str(dict(item).get("issue_code") or "") + if code in counts: + counts[code] += _safe_int(dict(item).get("count")) + return counts + + +def _chapter_count(world: Dict[str, Any]) -> int: + return max( + 1, + _safe_int(world.get("route_longevity")) + or _safe_int(world.get("completed_chapters")) + or _safe_int(world.get("route_longevity_target")) + or _safe_int(world.get("chapter_budget")), + ) + + +def build_issue_delta_summary( + *, + current: Dict[str, Any], + baseline: Dict[str, Any], + target_worlds: Sequence[str], +) -> Dict[str, Any]: + current_worlds = _world_lookup(current) + baseline_worlds = _world_lookup(baseline) + world_deltas: List[Dict[str, Any]] = [] + aggregate_before = {code: 0 for code in TARGET_ISSUE_CODES} + aggregate_after = {code: 0 for code in TARGET_ISSUE_CODES} + aggregate_before_chapters = 0 + aggregate_after_chapters = 0 + for world_id in target_worlds: + before_world = baseline_worlds.get(world_id, {}) + after_world = current_worlds.get(world_id, {}) + before_counts = _issue_counts(before_world) + after_counts = _issue_counts(after_world) + before_chapters = _chapter_count(before_world) + after_chapters = _chapter_count(after_world) + aggregate_before_chapters += before_chapters + aggregate_after_chapters += after_chapters + issue_deltas: Dict[str, Dict[str, Any]] = {} + for code in TARGET_ISSUE_CODES: + before_count = before_counts.get(code, 0) + after_count = after_counts.get(code, 0) + aggregate_before[code] += before_count + aggregate_after[code] += after_count + before_rate = _rate(before_count, before_chapters) + after_rate = _rate(after_count, after_chapters) + issue_deltas[code] = { + "before_count": before_count, + "after_count": after_count, + "count_delta": after_count - before_count, + "before_rate": before_rate, + "after_rate": after_rate, + "rate_delta": round(after_rate - before_rate, 3), + } + world_deltas.append( + { + "world_id": world_id, + "before_completed_chapters": before_chapters, + "after_completed_chapters": after_chapters, + "before_pass_rate": before_world.get("pass_rate"), + "after_pass_rate": after_world.get("pass_rate"), + "before_completion_ratio": before_world.get("completion_ratio"), + "after_completion_ratio": after_world.get("completion_ratio"), + "before_stop_reason": before_world.get("stop_reason"), + "after_stop_reason": after_world.get("stop_reason"), + "issue_deltas": issue_deltas, + } + ) + aggregate = {} + for code in TARGET_ISSUE_CODES: + before_rate = _rate(aggregate_before[code], aggregate_before_chapters) + after_rate = _rate(aggregate_after[code], aggregate_after_chapters) + aggregate[code] = { + "before_count": aggregate_before[code], + "after_count": aggregate_after[code], + "count_delta": aggregate_after[code] - aggregate_before[code], + "before_rate": before_rate, + "after_rate": after_rate, + "rate_delta": round(after_rate - before_rate, 3), + } + return { + "issue_codes": list(TARGET_ISSUE_CODES), + "target_worlds": list(target_worlds), + "aggregate": aggregate, + "world_deltas": world_deltas, + } + + +def _summarize_receipt_subset(receipts: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + receipt_count = len(receipts) + fallback_count = sum(1 for item in receipts if item.get("fallback_used")) + length_retry_count = sum( + 1 + for item in receipts + if bool(item.get("renderer_length_retry_used")) or _safe_int(item.get("renderer_attempt_count")) > 1 + ) + renderer_attempt_total = sum(_safe_int(item.get("renderer_attempt_count")) for item in receipts) + selected_renderer_providers = Counter(str(item.get("renderer_selected_provider") or "unknown") for item in receipts) + fallback_reasons = Counter( + str(item.get("renderer_fallback_reason") or "none") + for item in receipts + if item.get("fallback_used") or item.get("renderer_fallback_reason") + ) + backend_errors = Counter( + str(item.get("renderer_backend_error") or item.get("backend_error") or "none") + for item in receipts + if item.get("renderer_backend_error") or item.get("backend_error") + ) + return { + "receipt_count": receipt_count, + "deepseek_selected_count": selected_renderer_providers.get("deepseek", 0), + "fallback_count": fallback_count, + "fallback_rate": _rate(fallback_count, receipt_count), + "length_retry_count": length_retry_count, + "length_retry_rate": _rate(length_retry_count, receipt_count), + "avg_renderer_attempt_count": round(float(renderer_attempt_total) / float(max(1, receipt_count)), 3), + "selected_renderer_providers": dict(sorted(selected_renderer_providers.items())), + "renderer_fallback_reasons": dict(sorted(fallback_reasons.items())), + "renderer_backend_errors": dict(sorted(backend_errors.items())), + "renderer_latency": _latency_summary([item.get("renderer_latency_ms") for item in receipts]), + } + + +def build_receipt_metrics( + *, + receipts: Sequence[Dict[str, Any]], + target_worlds: Sequence[str], +) -> Dict[str, Any]: + filtered = [ + dict(item) + for item in receipts + if not target_worlds or str(item.get("world_id") or "") in set(target_worlds) + ] + return { + "global": _summarize_receipt_subset(filtered), + "by_world": { + world_id: _summarize_receipt_subset( + [item for item in filtered if str(item.get("world_id") or "") == world_id] + ) + for world_id in target_worlds + }, + } + + +def build_shadow_provider_routing(renderer_backend: LLMBackend) -> ProviderRoutingService: + return ProviderRoutingService( + candidate_backend=None, + renderer_backend=renderer_backend, + fallback_renderer=TemplateRenderer(), + ) + + +@contextmanager +def _temporary_env(overrides: Dict[str, str]) -> Iterator[None]: + sentinel = object() + previous: Dict[str, Any] = {key: os.environ.get(key, sentinel) for key in overrides} + try: + for key, value in overrides.items(): + os.environ[key] = value + yield + finally: + for key, value in previous.items(): + if value is sentinel: + os.environ.pop(key, None) + else: + os.environ[key] = str(value) + + +def _model_slug(model: str) -> str: + return str(model).replace("/", "_").replace(":", "_") + + +def _build_markdown_report(summary: Dict[str, Any]) -> str: + lines = [ + "# DeepSeek V4 Renderer Shadow Eval", + "", + "- generated_at: %s" % summary.get("generated_at"), + "- target_worlds: %s" % ", ".join(summary.get("target_worlds") or []), + "- max_chapters: %s" % summary.get("max_chapters"), + "- rollback_point: remove `deepseek` from renderer provider order", + "- recommendation: %s" % summary.get("recommendation"), + "", + ] + for model_report in summary.get("model_reports", []): + metrics = dict(dict(model_report.get("receipt_metrics") or {}).get("global") or {}) + lines.extend( + [ + "## %s" % model_report.get("model"), + "", + "- receipts: %s" % metrics.get("receipt_count", 0), + "- deepseek_selected_count: %s" % metrics.get("deepseek_selected_count", 0), + "- fallback_rate: %.3f" % float(metrics.get("fallback_rate", 0.0) or 0.0), + "- length_retry_rate: %.3f" % float(metrics.get("length_retry_rate", 0.0) or 0.0), + "- renderer_latency_avg_ms: %s" % dict(metrics.get("renderer_latency") or {}).get("avg_latency_ms"), + "", + "### Q03/Q04/Q05/Q09 Aggregate Deltas", + "", + ] + ) + aggregate = dict(dict(model_report.get("issue_delta_summary") or {}).get("aggregate") or {}) + for code in TARGET_ISSUE_CODES: + item = dict(aggregate.get(code) or {}) + lines.append( + "- %s: count %s -> %s (%+d), rate %.3f -> %.3f (%+.3f)" + % ( + code, + item.get("before_count", 0), + item.get("after_count", 0), + int(item.get("count_delta", 0) or 0), + float(item.get("before_rate", 0.0) or 0.0), + float(item.get("after_rate", 0.0) or 0.0), + float(item.get("rate_delta", 0.0) or 0.0), + ) + ) + lines.extend(["", "### Per World", ""]) + for world in dict(model_report.get("receipt_metrics") or {}).get("by_world", {}): + world_metrics = dict(dict(model_report.get("receipt_metrics") or {}).get("by_world", {}).get(world) or {}) + lines.append( + "- %s: fallback %.3f, length_retry %.3f, receipts %s" + % ( + world, + float(world_metrics.get("fallback_rate", 0.0) or 0.0), + float(world_metrics.get("length_retry_rate", 0.0) or 0.0), + world_metrics.get("receipt_count", 0), + ) + ) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def _recommendation(model_reports: Sequence[Dict[str, Any]]) -> str: + if not model_reports: + return "rollback_renderer_order_no_eval" + for report in model_reports: + metrics = dict(dict(report.get("receipt_metrics") or {}).get("global") or {}) + if int(metrics.get("receipt_count", 0) or 0) <= 0: + return "rollback_renderer_order_no_receipts" + if int(metrics.get("deepseek_selected_count", 0) or 0) <= 0: + return "rollback_renderer_order_deepseek_not_selected" + if any( + float(dict(dict(report.get("receipt_metrics") or {}).get("global") or {}).get("fallback_rate", 0.0) or 0.0) > 0.2 + or float(dict(dict(report.get("receipt_metrics") or {}).get("global") or {}).get("length_retry_rate", 0.0) or 0.0) > 0.2 + for report in model_reports + ): + return "stay_shadow_high_fallback_or_length_retry" + for report in model_reports: + aggregate = dict(dict(report.get("issue_delta_summary") or {}).get("aggregate") or {}) + if any(int(dict(aggregate.get(code) or {}).get("count_delta", 0) or 0) > 0 for code in TARGET_ISSUE_CODES): + return "stay_shadow_target_issue_regression" + return "eligible_for_canary_review_after_human_sample" + + +def _compact_model_report(report: Dict[str, Any]) -> Dict[str, Any]: + benchmark = dict(report.get("benchmark_summary") or {}) + return { + "model": report.get("model"), + "generated_at": report.get("generated_at"), + "target_worlds": list(report.get("target_worlds") or []), + "max_chapters": report.get("max_chapters"), + "artifact_path": report.get("artifact_path"), + "database_url": report.get("database_url"), + "golden_dir": report.get("golden_dir"), + "receipt_metrics": dict(report.get("receipt_metrics") or {}), + "issue_delta_summary": dict(report.get("issue_delta_summary") or {}), + "benchmark_digest": { + "benchmark_mode": benchmark.get("benchmark_mode"), + "acceptance_profile": benchmark.get("acceptance_profile"), + "chapter_budget": benchmark.get("chapter_budget"), + "cross_pack_pass_rate": benchmark.get("cross_pack_pass_rate"), + "weakest_packs": list(benchmark.get("weakest_packs") or []), + "strongest_packs": list(benchmark.get("strongest_packs") or []), + "phase_a_quality_gate": dict(benchmark.get("phase_a_quality_gate") or {}), + }, + } + + +def run_model_eval( + *, + model: str, + target_worlds: Sequence[str], + baseline: Dict[str, Any], + output_dir: Path, + max_chapters: int, + acceptance_profile: str, + benchmark_mode: Optional[str], + renderer_max_attempts: int, +) -> Dict[str, Any]: + model_dir = output_dir / _model_slug(model) + model_dir.mkdir(parents=True, exist_ok=True) + database_url = "sqlite:///%s" % (model_dir / "shadow_eval.sqlite3") + repository = SQLAlchemyRepository(database_url=database_url) + registry = FileSystemWorldRegistry() + observability = ObservabilityService(repository) + + env_overrides = { + "NARRATIVEOS_DEEPSEEK_MODEL": model, + "NARRATIVEOS_LLM_RENDERER_PROVIDER_ORDER": "deepseek,local", + "NARRATIVEOS_LLM_RENDERER_CACHE_ENABLED": "false", + "NARRATIVEOS_LLM_RENDERER_MAX_ATTEMPTS": str(max(1, int(renderer_max_attempts or 1))), + "NARRATIVEOS_LLM_CANDIDATE_PROVIDER_ORDER": "local", + } + with _temporary_env(env_overrides): + renderer_backend = build_llm_backend_from_env(scope="renderer") + if renderer_backend is None: + raise RuntimeError("renderer backend did not initialize; check DEEPSEEK_API_KEY and provider order") + provider_routing = build_shadow_provider_routing(renderer_backend) + authoring = AuthoringService( + repository, + registry=registry, + provider_routing_service=provider_routing, + observability_service=observability, + ) + + def simulation_runner(_world_id: str, world_version_id: str) -> Dict[str, Any]: + return authoring.run_simulation_for_world_version( + world_version_id, + include_cross_pack=False, + max_chapters=max_chapters, + ) + + benchmark_summary = run_benchmark( + repository=repository, + golden_dir=model_dir / "goldens", + worldpack=list(target_worlds), + baseline=baseline, + simulation_runner=simulation_runner, + benchmark_mode=benchmark_mode, + max_chapters=max_chapters, + acceptance_profile=acceptance_profile, + fast_gate_weakest_limit=len(target_worlds), + ) + + receipts = observability.list_runtime_receipts(limit=max(500, max_chapters * len(target_worlds) * 4)) + if not receipts: + raise RuntimeError("shadow eval produced no runtime receipts for %s" % model) + receipt_metrics = build_receipt_metrics(receipts=receipts, target_worlds=target_worlds) + if int(dict(receipt_metrics["global"]).get("deepseek_selected_count", 0) or 0) <= 0: + raise RuntimeError("DeepSeek was not selected as renderer for %s" % model) + + model_report = { + "model": model, + "generated_at": _utcnow(), + "target_worlds": list(target_worlds), + "max_chapters": max_chapters, + "database_url": database_url, + "golden_dir": str(model_dir / "goldens"), + "benchmark_summary": benchmark_summary, + "issue_delta_summary": build_issue_delta_summary( + current=benchmark_summary, + baseline=baseline, + target_worlds=target_worlds, + ), + "receipt_metrics": receipt_metrics, + "provider_runtime_metrics": observability.provider_runtime_metrics(limit=max(500, len(receipts) * 2)), + } + json_path = model_dir / "shadow_eval_summary.json" + model_report["artifact_path"] = str(json_path) + _write_json(json_path, model_report) + return model_report + + +def build_combined_report( + *, + model_reports: Sequence[Dict[str, Any]], + target_worlds: Sequence[str], + max_chapters: int, + output_dir: Path, +) -> Dict[str, Any]: + recommendation = _recommendation(model_reports) + compact_reports = [_compact_model_report(dict(item)) for item in model_reports] + report = { + "generated_at": _utcnow(), + "target_worlds": list(target_worlds), + "max_chapters": max_chapters, + "model_reports": compact_reports, + "comparison": { + str(item.get("model")): { + "fallback_rate": dict(dict(item.get("receipt_metrics") or {}).get("global") or {}).get("fallback_rate"), + "length_retry_rate": dict(dict(item.get("receipt_metrics") or {}).get("global") or {}).get("length_retry_rate"), + "deepseek_selected_count": dict(dict(item.get("receipt_metrics") or {}).get("global") or {}).get("deepseek_selected_count"), + "q_deltas": dict(dict(item.get("issue_delta_summary") or {}).get("aggregate") or {}), + } + for item in model_reports + }, + "recommendation": recommendation, + "rollback_point": "remove deepseek from NARRATIVEOS_LLM_RENDERER_PROVIDER_ORDER", + } + json_path = output_dir / "deepseek_v4_renderer_shadow_eval.json" + markdown_path = output_dir / "deepseek_v4_renderer_shadow_eval.md" + report["artifact_paths"] = { + "json": str(json_path), + "markdown": str(markdown_path), + } + _write_json(json_path, report) + markdown_path.write_text(_build_markdown_report(report), encoding="utf-8") + return report + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run DeepSeek V4 renderer shadow eval on weakest packs.") + parser.add_argument("--model", action="append", default=[]) + parser.add_argument("--worldpack", action="append", default=[]) + parser.add_argument("--baseline-file", default="tests/benchmark_baseline.json") + parser.add_argument("--max-chapters", type=int, default=6) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--run-id", default=None) + parser.add_argument("--acceptance-profile", choices=["fast", "nightly", "full"], default="fast") + parser.add_argument("--benchmark-mode", default=None) + parser.add_argument("--renderer-max-attempts", type=int, default=1) + args = parser.parse_args(list(argv) if argv is not None else None) + + if not os.getenv("DEEPSEEK_API_KEY"): + raise SystemExit("DEEPSEEK_API_KEY is required and must be supplied via the shell environment") + + models = [str(item).strip() for item in args.model if str(item).strip()] or [ + "deepseek-v4-flash", + "deepseek-v4-pro", + ] + target_worlds = [str(item).strip() for item in args.worldpack if str(item).strip()] or list(DEFAULT_WEAKEST_PACKS) + baseline_path = Path(args.baseline_file) + baseline = _load_json(baseline_path) if baseline_path.exists() else {} + run_root = Path(args.output_dir) / (args.run_id or _run_id()) + run_root.mkdir(parents=True, exist_ok=True) + + model_reports = [ + run_model_eval( + model=model, + target_worlds=target_worlds, + baseline=baseline, + output_dir=run_root, + max_chapters=int(args.max_chapters), + acceptance_profile=str(args.acceptance_profile), + benchmark_mode=str(args.benchmark_mode) if args.benchmark_mode else None, + renderer_max_attempts=int(args.renderer_max_attempts), + ) + for model in models + ] + combined = build_combined_report( + model_reports=model_reports, + target_worlds=target_worlds, + max_chapters=int(args.max_chapters), + output_dir=run_root, + ) + print(json.dumps(combined, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_grounding_eval.py b/scripts/run_grounding_eval.py new file mode 100644 index 0000000..891edef --- /dev/null +++ b/scripts/run_grounding_eval.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.narrativeos.quality.grounding import build_grounding_decision +from src.narrativeos.schemas import validate_payload + +SAMPLE_SCHEMA = "quality_eval_sample.schema.json" +RUN_SCHEMA = "quality_eval_run.schema.json" + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _samples(root: Path): + for bucket in ["normal", "boundary", "adversarial"]: + for path in sorted((root / "tests" / "fixtures" / "quality_eval" / bucket).glob("*.json")): + yield bucket, path + + +def main() -> int: + run_id = f"grounding_eval_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + output_dir = ROOT / "artifacts" / "quality_eval" / run_id + output_dir.mkdir(parents=True, exist_ok=True) + + results = [] + failures = [] + for bucket, path in _samples(ROOT): + sample = json.loads(path.read_text(encoding="utf-8")) + validate_payload(sample, SAMPLE_SCHEMA) + decision = build_grounding_decision( + scenario_id=sample["scenario"], + text=sample["input"]["text"], + coverage_context=sample.get("context"), + state_after=type("State", (), {"world_facts": sample.get("materials", {}).get("world_facts", []), "open_promises": []})(), + worldpack_payload={"world_bible": sample.get("materials", {}).get("world_bible", {})}, + ) + expected_status = sample["grounding_expectation"]["status"] + passed = decision.status == expected_status or (expected_status == "passed" and decision.status == "weak") + result = { + "sample_id": sample["sample_id"], + "bucket": bucket, + "expected_status": expected_status, + "actual_status": decision.status, + "confidence": decision.confidence, + "unsupported_claims": decision.unsupported_claims, + "passed": passed, + } + results.append(result) + if not passed: + failures.append(result) + + summary = { + "run_id": run_id, + "generated_at": _utcnow(), + "sample_count": len(results), + "overall_pass_rate": round(sum(1 for item in results if item["passed"]) / float(max(1, len(results))), 3), + "veto_rate": 0.0, + "average_content_score": 0.0, + "grounding_pass_rate": round(sum(1 for item in results if item["actual_status"] == "passed") / float(max(1, len(results))), 3), + "failed_sample_count": len(failures), + "failed_samples": failures, + } + validate_payload(summary, RUN_SCHEMA) + (output_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + (output_dir / "failed_samples.json").write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding="utf-8") + (output_dir / "metrics.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + (output_dir / "summary.md").write_text( + "# Grounding Eval\n\n" + f"- run_id: {run_id}\n" + f"- sample_count: {summary['sample_count']}\n" + f"- overall_pass_rate: {summary['overall_pass_rate']}\n" + f"- grounding_pass_rate: {summary['grounding_pass_rate']}\n" + f"- failed_sample_count: {summary['failed_sample_count']}\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase0_guardrails.sh b/scripts/run_phase0_guardrails.sh index 4e03dfe..6a7a996 100644 --- a/scripts/run_phase0_guardrails.sh +++ b/scripts/run_phase0_guardrails.sh @@ -101,4 +101,66 @@ if [[ ! -f "$BENCHMARK_BASELINE_MD" ]]; then exit 1 fi -diff -u "$BENCHMARK_BASELINE_MD" "$BENCHMARK_MD" +normalize_benchmark_summary() { + python - "$1" <<'PY' +import re +import sys + +path = sys.argv[1] +section = "" +with open(path, "r", encoding="utf-8") as handle: + for line in handle: + if line.startswith("## ") or line.startswith("### "): + section = line.strip() + + if line.startswith("- total wall ms:"): + line = re.sub(r"(- total wall ms: )\d+(?:\.\d+)?", r"\1", line) + elif line.startswith("- slowest worlds:"): + line = "- slowest worlds: \n" + elif line.startswith("- stage totals:"): + line = re.sub(r"\d+(?:\.\d+)?ms", "", line) + elif line.startswith("- quality-pass stage actions:"): + line = "- quality-pass stage actions: \n" + elif line.startswith("- weakest packs evaluated:"): + line = "- weakest packs evaluated: \n" + elif line.startswith("- watch worlds:"): + line = "- watch worlds: \n" + elif line.startswith("- stop-ready worlds:"): + line = "- stop-ready worlds: \n" + elif line.startswith("- continue worlds:"): + line = "- continue worlds: \n" + elif line.startswith("- strongest packs changed:"): + line = "- strongest packs changed: \n" + elif line.startswith("- weakest packs changed:"): + line = "- weakest packs changed: \n" + elif line.startswith("- current strongest:"): + line = "- current strongest: \n" + elif line.startswith("- current weakest:"): + line = "- current weakest: \n" + elif section == "### Commercial Weakest-Pack Evidence" and re.match(r"^- [^:]+: long-route ", line): + line = "- : long-route \n" + elif section in {"## Strongest Packs", "## Weakest Packs"} and re.match(r"^- [^:]+: pass ", line): + line = "- : pass \n" + elif section == "## Weakest Packs" and line.startswith(" completion ratio:"): + line = " completion ratio: \n" + elif section == "## Weakest Packs" and line.startswith(" weakest dimensions:"): + line = " weakest dimensions: \n" + elif section == "## Weakest Pack Diagnostics" and re.match(r"^- [^:]+: diagnostic rank ", line): + line = "- : diagnostic rank \n" + elif section == "## Weakest Pack Diagnostics" and line.startswith(" worst chapters:"): + line = " worst chapters: \n" + elif section == "## Weakest Pack Diagnostics" and line.startswith(" module / asset / policy:"): + line = " module / asset / policy: \n" + elif section == "## Weakest Pack Diagnostics" and line.startswith(" next fixes:"): + line = " next fixes: \n" + elif section == "## Weakest Pack Polish Program" and re.match(r"^- .+ \u00b7 ", line): + line = "- : dimensions \n" + sys.stdout.write(line) +PY +} + +NORMALIZED_BASELINE_MD="$(mktemp)" +NORMALIZED_BENCHMARK_MD="$(mktemp)" +normalize_benchmark_summary "$BENCHMARK_BASELINE_MD" > "$NORMALIZED_BASELINE_MD" +normalize_benchmark_summary "$BENCHMARK_MD" > "$NORMALIZED_BENCHMARK_MD" +diff -u "$NORMALIZED_BASELINE_MD" "$NORMALIZED_BENCHMARK_MD" diff --git a/scripts/run_quality_eval.py b/scripts/run_quality_eval.py new file mode 100644 index 0000000..3bfb799 --- /dev/null +++ b/scripts/run_quality_eval.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.narrativeos.models import EvaluationDecision, EvaluationReport, EvaluationScores +from src.narrativeos.quality.adapter import build_guardrail_records +from src.narrativeos.quality.grounding import build_grounding_decision +from src.narrativeos.schemas import validate_payload + +SAMPLE_SCHEMA = "quality_eval_sample.schema.json" +RUN_SCHEMA = "quality_eval_run.schema.json" + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _samples(root: Path): + for bucket in ["normal", "boundary", "adversarial"]: + for path in sorted((root / "tests" / "fixtures" / "quality_eval" / bucket).glob("*.json")): + yield bucket, path + + +def main() -> int: + run_id = f"quality_eval_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + output_dir = ROOT / "artifacts" / "quality_eval" / run_id + output_dir.mkdir(parents=True, exist_ok=True) + + results = [] + failures = [] + for bucket, path in _samples(ROOT): + sample = json.loads(path.read_text(encoding="utf-8")) + validate_payload(sample, SAMPLE_SCHEMA) + report = EvaluationReport( + chapter_id=sample["sample_id"], + world_version_id="quality_eval@fixture", + session_id="quality_eval_session", + decision=EvaluationDecision(decision="pass", reason="fixture"), + issues=[], + scores=EvaluationScores( + readability=0.8, + scene_density=0.7, + character_fidelity=0.7, + causal_continuity=0.7, + pacing=0.7, + choice_distinctness=0.6, + hook_quality=0.6, + monetize_ready=0.5, + overall_score=0.7, + ), + hard_validator_results={"failed": False}, + summary="fixture", + created_at=_utcnow(), + ) + quality_bundle = { + "report": report, + "quality_gate": { + "ok": not bool(sample["expected_veto"]), + "enforced_decision": "block" if sample["expected_veto"] else "pass", + "failed_checks": list(sample.get("expected_reason_codes") or []), + "failed_contract_checks": [], + }, + } + records = build_guardrail_records( + quality_bundle=quality_bundle, + scenario_id=sample["scenario"], + source_surface="eval_harness", + source_ref={"kind": "fixture", "chapter_id": sample["sample_id"], "rendered_text": sample["input"]["text"]}, + world_version_id="quality_eval@fixture", + session_id="quality_eval_session", + chapter_id=sample["sample_id"], + coverage_context=sample.get("context"), + state_after=type("State", (), {"world_facts": sample.get("materials", {}).get("world_facts", []), "open_promises": []})(), + worldpack_payload={"world_bible": sample.get("materials", {}).get("world_bible", {})}, + ) + grounding = build_grounding_decision( + scenario_id=sample["scenario"], + text=sample["input"]["text"], + coverage_context=sample.get("context"), + state_after=type("State", (), {"world_facts": sample.get("materials", {}).get("world_facts", []), "open_promises": []})(), + worldpack_payload={"world_bible": sample.get("materials", {}).get("world_bible", {})}, + ) + passed = ( + records["decision"].status in {"passed", "review_required", "blocked"} + and grounding.status in {sample["grounding_expectation"]["status"], "weak"} + ) + result = { + "sample_id": sample["sample_id"], + "bucket": bucket, + "guardrail_status": records["decision"].status, + "grounding_status": grounding.status, + "overall_score": records["score"].overall_score, + "passed": passed, + } + results.append(result) + if not passed: + failures.append(result) + + summary = { + "run_id": run_id, + "generated_at": _utcnow(), + "sample_count": len(results), + "overall_pass_rate": round(sum(1 for item in results if item["passed"]) / float(max(1, len(results))), 3), + "veto_rate": round(sum(1 for item in results if item["guardrail_status"] == "blocked") / float(max(1, len(results))), 3), + "average_content_score": round(sum(item["overall_score"] for item in results) / float(max(1, len(results))), 3), + "grounding_pass_rate": round(sum(1 for item in results if item["grounding_status"] == "passed") / float(max(1, len(results))), 3), + "failed_sample_count": len(failures), + "failed_samples": failures, + } + validate_payload(summary, RUN_SCHEMA) + (output_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + (output_dir / "failed_samples.json").write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding="utf-8") + (output_dir / "metrics.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + (output_dir / "summary.md").write_text( + "# Quality Eval\n\n" + f"- run_id: {run_id}\n" + f"- sample_count: {summary['sample_count']}\n" + f"- overall_pass_rate: {summary['overall_pass_rate']}\n" + f"- veto_rate: {summary['veto_rate']}\n" + f"- average_content_score: {summary['average_content_score']}\n" + f"- grounding_pass_rate: {summary['grounding_pass_rate']}\n" + f"- failed_sample_count: {summary['failed_sample_count']}\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_reader_storybook_500_verification.sh b/scripts/run_reader_storybook_500_verification.sh new file mode 100755 index 0000000..47b4b7e --- /dev/null +++ b/scripts/run_reader_storybook_500_verification.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PYTHON_BIN="${NARRATIVEOS_PYTHON:-}" +if [[ -z "${PYTHON_BIN}" ]]; then + if [[ -x "${ROOT_DIR}/.venv311/bin/python" ]]; then + PYTHON_BIN="${ROOT_DIR}/.venv311/bin/python" + else + PYTHON_BIN="python" + fi +fi + +BACKEND_PORT="${BACKEND_PORT:-8000}" +FRONTEND_PORT="${FRONTEND_PORT:-3000}" +FRONTEND_URL="${FRONTEND_URL:-http://127.0.0.1:${FRONTEND_PORT}}" +BACKEND_URL="${BACKEND_URL:-http://127.0.0.1:${BACKEND_PORT}}" +CHROME_PORT="${CHROME_PORT:-9225}" +CHROME_USER_DIR="${CHROME_USER_DIR:-/tmp/narrativeos-chrome-reader-storybook-500}" +CHROME_APP="${CHROME_APP:-/Applications/Google Chrome.app}" +CHROME_BIN="${CHROME_BIN:-}" +CHROME_EXTRA_ARGS="${CHROME_EXTRA_ARGS:-}" +CI_HEADLESS="${CI_HEADLESS:-${CI:-}}" +REUSE_BACKEND="${REUSE_BACKEND:-0}" +REUSE_SEEDED_DB="${REUSE_SEEDED_DB:-1}" +TARGET_CHAPTERS="${TARGET_CHAPTERS:-500}" +MIN_TARGET_CHAPTERS="${MIN_TARGET_CHAPTERS:-500}" +MAX_ATTEMPTS_PER_CHAPTER="${MAX_ATTEMPTS_PER_CHAPTER:-4}" + +ARTIFACT_PREFIX="${ARTIFACT_PREFIX:-reader_storybook_500_20260425}" +DB_FILE="${DB_FILE:-${ROOT_DIR}/artifacts/${ARTIFACT_PREFIX}.db}" +SEED_FILE="${SEED_FILE:-${ROOT_DIR}/artifacts/${ARTIFACT_PREFIX}_seed.json}" +RESULT_FILE="${RESULT_FILE:-${ROOT_DIR}/artifacts/${ARTIFACT_PREFIX}_result.json}" +FAILURE_ARTIFACT_FILE="${FAILURE_ARTIFACT_FILE:-${ROOT_DIR}/artifacts/${ARTIFACT_PREFIX}_failure_snapshot.json}" +SCREENSHOT_DIR="${SCREENSHOT_DIR:-${ROOT_DIR}/artifacts/${ARTIFACT_PREFIX}_screenshots}" +AUDIT_FILE="${AUDIT_FILE:-${ROOT_DIR}/artifacts/${ARTIFACT_PREFIX}_redundancy_audit.json}" +AUDIT_MARKDOWN_FILE="${AUDIT_MARKDOWN_FILE:-${ROOT_DIR}/artifacts/${ARTIFACT_PREFIX}_redundancy_audit.md}" +SERVER_LOG="${SERVER_LOG:-/tmp/reader_storybook_500_backend.log}" +FRONTEND_LOG="${FRONTEND_LOG:-/tmp/reader_storybook_500_frontend.log}" +CHROME_LOG="${CHROME_LOG:-/tmp/reader_storybook_500_chrome.log}" +SERVER_PID="" +FRONTEND_PID="" +CHROME_PID="" + +cleanup() { + if [[ -n "${SERVER_PID}" ]] && kill -0 "${SERVER_PID}" >/dev/null 2>&1; then + kill "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" 2>/dev/null || true + fi + if [[ -n "${FRONTEND_PID}" ]] && kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then + kill "${FRONTEND_PID}" >/dev/null 2>&1 || true + wait "${FRONTEND_PID}" 2>/dev/null || true + fi + if [[ -n "${CHROME_PID}" ]] && kill -0 "${CHROME_PID}" >/dev/null 2>&1; then + kill "${CHROME_PID}" >/dev/null 2>&1 || true + wait "${CHROME_PID}" 2>/dev/null || true + fi + pkill -f "remote-debugging-port=${CHROME_PORT}.*${CHROME_USER_DIR}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +find_chrome_bin() { + if [[ -n "${CHROME_BIN}" ]] && [[ -x "${CHROME_BIN}" ]]; then + printf '%s\n' "${CHROME_BIN}" + return 0 + fi + if [[ -d "${CHROME_APP}" ]] && [[ -x "${CHROME_APP}/Contents/MacOS/Google Chrome" ]]; then + printf '%s\n' "${CHROME_APP}/Contents/MacOS/Google Chrome" + return 0 + fi + for candidate in google-chrome google-chrome-stable chromium chromium-browser; do + if command -v "${candidate}" >/dev/null 2>&1; then + command -v "${candidate}" + return 0 + fi + done + return 1 +} + +mkdir -p "${ROOT_DIR}/artifacts" "${SCREENSHOT_DIR}" +if [[ "${REUSE_SEEDED_DB}" != "1" || ! -f "${DB_FILE}" || ! -f "${SEED_FILE}" ]]; then + rm -f "${DB_FILE}" "${SEED_FILE}" +fi +rm -f "${RESULT_FILE}" "${FAILURE_ARTIFACT_FILE}" "${AUDIT_FILE}" "${AUDIT_MARKDOWN_FILE}" +rm -rf "${SCREENSHOT_DIR}" "${CHROME_USER_DIR}" +mkdir -p "${SCREENSHOT_DIR}" +rm -f "${SERVER_LOG}" "${FRONTEND_LOG}" "${CHROME_LOG}" + +DATABASE_URL="sqlite:///${DB_FILE}" + +if [[ -f "${DB_FILE}" && -f "${SEED_FILE}" ]]; then + echo "Reusing seeded 500-chapter Reader Storybook replay data at ${DB_FILE}." +else + echo "Seeding 500-chapter Reader Storybook replay data..." + "${PYTHON_BIN}" "${ROOT_DIR}/scripts/seed_reader_storybook_long_route_smoke.py" \ + --database-url "${DATABASE_URL}" \ + --output "${SEED_FILE}" \ + --world-ids all \ + --target-chapters "${TARGET_CHAPTERS}" \ + --min-target-chapters "${MIN_TARGET_CHAPTERS}" \ + --max-attempts-per-chapter "${MAX_ATTEMPTS_PER_CHAPTER}" >/dev/null +fi + +if curl -sf "${BACKEND_URL}/health" >/dev/null 2>&1; then + if [[ "${REUSE_BACKEND}" != "1" ]]; then + echo "Backend port ${BACKEND_PORT} is already in use. Stop the existing backend or set REUSE_BACKEND=1 if it already points at ${DB_FILE}." >&2 + exit 1 + fi + echo "Reusing existing backend at ${BACKEND_URL}." +else + echo "Starting NarrativeOS backend on fixed default port ${BACKEND_PORT}..." + ( + cd "${ROOT_DIR}" + export DATABASE_URL + exec "${PYTHON_BIN}" -m uvicorn src.narrativeos.api:app --host 127.0.0.1 --port "${BACKEND_PORT}" + ) >"${SERVER_LOG}" 2>&1 & + SERVER_PID="$!" + for _ in $(seq 1 45); do + if curl -sf "${BACKEND_URL}/health" >/dev/null 2>&1; then + break + fi + sleep 1 + done +fi + +if ! curl -sf "${BACKEND_URL}/health" >/dev/null 2>&1; then + echo "Backend failed to start. Server log:" >&2 + cat "${SERVER_LOG}" >&2 || true + exit 1 +fi + +if curl -sf "${FRONTEND_URL}" >/dev/null 2>&1; then + echo "Reusing existing Quantum frontend at ${FRONTEND_URL}." +else + echo "Starting Quantum frontend on fixed default port ${FRONTEND_PORT}..." + ( + cd "${ROOT_DIR}/Kimi_Agent_设计系统加载/app" + export NARRATIVEOS_API_ORIGIN="${BACKEND_URL}" + exec npm run dev -- --host 127.0.0.1 --port "${FRONTEND_PORT}" --strictPort + ) >"${FRONTEND_LOG}" 2>&1 & + FRONTEND_PID="$!" + for _ in $(seq 1 45); do + if curl -sf "${FRONTEND_URL}" >/dev/null 2>&1; then + break + fi + sleep 1 + done +fi + +if ! curl -sf "${FRONTEND_URL}" >/dev/null 2>&1; then + echo "Quantum frontend failed to start. Frontend log:" >&2 + cat "${FRONTEND_LOG}" >&2 || true + exit 1 +fi + +if ! CHROME_BIN_RESOLVED="$(find_chrome_bin)"; then + echo "Unable to locate a Chrome/Chromium binary. Set CHROME_BIN or install Google Chrome." >&2 + exit 1 +fi + +echo "Launching Chrome with remote debugging on port ${CHROME_PORT}..." +if [[ -n "${CI_HEADLESS}" && "${CI_HEADLESS}" != "0" && "${CI_HEADLESS}" != "false" ]]; then + "${CHROME_BIN_RESOLVED}" \ + --headless=new \ + --disable-gpu \ + --no-sandbox \ + --no-first-run \ + --no-default-browser-check \ + --remote-debugging-port="${CHROME_PORT}" \ + --user-data-dir="${CHROME_USER_DIR}" \ + ${CHROME_EXTRA_ARGS} \ + about:blank >"${CHROME_LOG}" 2>&1 & + CHROME_PID="$!" +else + if [[ "${CHROME_BIN_RESOLVED}" == *"/Contents/MacOS/Google Chrome" ]]; then + open -na "${CHROME_APP}" --args \ + --remote-debugging-port="${CHROME_PORT}" \ + --user-data-dir="${CHROME_USER_DIR}" \ + ${CHROME_EXTRA_ARGS} \ + about:blank + else + "${CHROME_BIN_RESOLVED}" \ + --remote-debugging-port="${CHROME_PORT}" \ + --user-data-dir="${CHROME_USER_DIR}" \ + ${CHROME_EXTRA_ARGS} \ + about:blank >"${CHROME_LOG}" 2>&1 & + CHROME_PID="$!" + fi +fi + +for _ in $(seq 1 30); do + if curl -sf "http://127.0.0.1:${CHROME_PORT}/json/version" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! curl -sf "http://127.0.0.1:${CHROME_PORT}/json/version" >/dev/null 2>&1; then + echo "Chrome remote debugging did not start." >&2 + [[ -f "${CHROME_LOG}" ]] && cat "${CHROME_LOG}" >&2 + exit 1 +fi + +echo "Verifying 500-chapter Storybook in Quantum frontend on ${FRONTEND_URL}..." +node "${ROOT_DIR}/scripts/verify_reader_storybook_500_quantum.js" \ + --url "${FRONTEND_URL}" \ + --seed-file "${SEED_FILE}" \ + --result-file "${RESULT_FILE}" \ + --failure-artifact-file "${FAILURE_ARTIFACT_FILE}" \ + --screenshot-dir "${SCREENSHOT_DIR}" \ + --chrome-port "${CHROME_PORT}" + +echo "Writing human-perceived redundancy audit samples..." +"${PYTHON_BIN}" "${ROOT_DIR}/scripts/audit_reader_storybook_500_redundancy.py" \ + --database-url "${DATABASE_URL}" \ + --seed-file "${SEED_FILE}" \ + --output "${AUDIT_FILE}" \ + --markdown-output "${AUDIT_MARKDOWN_FILE}" >/dev/null + +echo "Reader Storybook 500 verification passed." diff --git a/scripts/run_reader_storybook_long_route_smoke.sh b/scripts/run_reader_storybook_long_route_smoke.sh new file mode 100644 index 0000000..ffa80f8 --- /dev/null +++ b/scripts/run_reader_storybook_long_route_smoke.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VENV_PYTHON="${ROOT_DIR}/.venv/bin/python" +SMOKE_DB="${ROOT_DIR}/artifacts/reader_storybook_long_route_smoke.db" +SEED_FILE="${ROOT_DIR}/artifacts/reader_storybook_long_route_smoke_seed.json" +RESULT_FILE="${ROOT_DIR}/artifacts/reader_storybook_long_route_smoke_result.json" +HISTORY_FILE="${ROOT_DIR}/artifacts/reader_storybook_long_route_smoke_history.json" +FAILURE_ARTIFACT_FILE="${ROOT_DIR}/artifacts/reader_storybook_long_route_smoke_failure_snapshot.json" +FAILURE_SCREENSHOT_FILE="${ROOT_DIR}/artifacts/reader_storybook_long_route_smoke_failure.png" +STORYBOOK_SCREENSHOT_FILE="${ROOT_DIR}/artifacts/reader_storybook_long_route_smoke_storybook.png" +APP_PORT="${APP_PORT:-8011}" +APP_URL="${APP_URL:-http://127.0.0.1:${APP_PORT}/app?debug=1}" +CHROME_PORT="${CHROME_PORT:-9225}" +CHROME_USER_DIR="${CHROME_USER_DIR:-/tmp/narrativeos-chrome-reader-storybook-long-route}" +CHROME_APP="${CHROME_APP:-/Applications/Google Chrome.app}" +CHROME_BIN="${CHROME_BIN:-}" +CHROME_EXTRA_ARGS="${CHROME_EXTRA_ARGS:-}" +CI_HEADLESS="${CI_HEADLESS:-${CI:-}}" +WORLD_IDS="${WORLD_IDS:-jade_court_exam,jade_court_romance,urban_mystery_lotus_lane}" +TARGET_CHAPTERS="${TARGET_CHAPTERS:-30}" +MIN_TARGET_CHAPTERS="${MIN_TARGET_CHAPTERS:-30}" +MAX_ATTEMPTS_PER_CHAPTER="${MAX_ATTEMPTS_PER_CHAPTER:-4}" +SERVER_LOG="${SERVER_LOG:-/tmp/reader_storybook_long_route_smoke_server.log}" +CHROME_LOG="${CHROME_LOG:-/tmp/reader_storybook_long_route_smoke_chrome.log}" +SERVER_PID="" +CHROME_PID="" + +cleanup() { + if [[ -n "${SERVER_PID}" ]] && kill -0 "${SERVER_PID}" >/dev/null 2>&1; then + kill "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" 2>/dev/null || true + fi + if [[ -n "${CHROME_PID}" ]] && kill -0 "${CHROME_PID}" >/dev/null 2>&1; then + kill "${CHROME_PID}" >/dev/null 2>&1 || true + wait "${CHROME_PID}" 2>/dev/null || true + fi + pkill -f "remote-debugging-port=${CHROME_PORT}.*${CHROME_USER_DIR}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +find_chrome_bin() { + if [[ -n "${CHROME_BIN}" ]] && [[ -x "${CHROME_BIN}" ]]; then + printf '%s\n' "${CHROME_BIN}" + return 0 + fi + if [[ -d "${CHROME_APP}" ]] && [[ -x "${CHROME_APP}/Contents/MacOS/Google Chrome" ]]; then + printf '%s\n' "${CHROME_APP}/Contents/MacOS/Google Chrome" + return 0 + fi + for candidate in google-chrome google-chrome-stable chromium chromium-browser; do + if command -v "${candidate}" >/dev/null 2>&1; then + command -v "${candidate}" + return 0 + fi + done + return 1 +} + +if [[ ! -x "${VENV_PYTHON}" ]]; then + echo "Missing virtualenv python: ${VENV_PYTHON}" >&2 + exit 1 +fi + +if ! CHROME_BIN_RESOLVED="$(find_chrome_bin)"; then + echo "Unable to locate a Chrome/Chromium binary. Set CHROME_BIN or install Google Chrome." >&2 + exit 1 +fi + +mkdir -p "${ROOT_DIR}/artifacts" +rm -f "${SMOKE_DB}" "${SEED_FILE}" "${RESULT_FILE}" "${FAILURE_ARTIFACT_FILE}" "${FAILURE_SCREENSHOT_FILE}" "${STORYBOOK_SCREENSHOT_FILE}" +rm -rf "${CHROME_USER_DIR}" +rm -f "${SERVER_LOG}" "${CHROME_LOG}" + +DATABASE_URL="sqlite:///${SMOKE_DB}" + +echo "Seeding reader storybook long-route dataset..." +"${VENV_PYTHON}" "${ROOT_DIR}/scripts/seed_reader_storybook_long_route_smoke.py" \ + --database-url "${DATABASE_URL}" \ + --output "${SEED_FILE}" \ + --world-ids "${WORLD_IDS}" \ + --target-chapters "${TARGET_CHAPTERS}" \ + --min-target-chapters "${MIN_TARGET_CHAPTERS}" \ + --max-attempts-per-chapter "${MAX_ATTEMPTS_PER_CHAPTER}" >/dev/null + +echo "Starting NarrativeOS API on port ${APP_PORT}..." +( + cd "${ROOT_DIR}" + export DATABASE_URL + exec "${VENV_PYTHON}" -m uvicorn src.narrativeos.api:app --host 127.0.0.1 --port "${APP_PORT}" +) >"${SERVER_LOG}" 2>&1 & +SERVER_PID="$!" + +for _ in $(seq 1 30); do + if curl -sf "http://127.0.0.1:${APP_PORT}/health" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! curl -sf "http://127.0.0.1:${APP_PORT}/health" >/dev/null 2>&1; then + echo "API failed to start. Server log:" >&2 + cat "${SERVER_LOG}" >&2 + exit 1 +fi + +echo "Launching Chrome with remote debugging on port ${CHROME_PORT}..." +if [[ -n "${CI_HEADLESS}" && "${CI_HEADLESS}" != "0" && "${CI_HEADLESS}" != "false" ]]; then + "${CHROME_BIN_RESOLVED}" \ + --headless=new \ + --disable-gpu \ + --no-sandbox \ + --no-first-run \ + --no-default-browser-check \ + --remote-debugging-port="${CHROME_PORT}" \ + --user-data-dir="${CHROME_USER_DIR}" \ + ${CHROME_EXTRA_ARGS} \ + about:blank >"${CHROME_LOG}" 2>&1 & + CHROME_PID="$!" +else + if [[ "${CHROME_BIN_RESOLVED}" == *"/Contents/MacOS/Google Chrome" ]]; then + open -na "${CHROME_APP}" --args \ + --remote-debugging-port="${CHROME_PORT}" \ + --user-data-dir="${CHROME_USER_DIR}" \ + ${CHROME_EXTRA_ARGS} \ + about:blank + else + "${CHROME_BIN_RESOLVED}" \ + --remote-debugging-port="${CHROME_PORT}" \ + --user-data-dir="${CHROME_USER_DIR}" \ + ${CHROME_EXTRA_ARGS} \ + about:blank >"${CHROME_LOG}" 2>&1 & + CHROME_PID="$!" + fi +fi + +for _ in $(seq 1 30); do + if curl -sf "http://127.0.0.1:${CHROME_PORT}/json/version" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! curl -sf "http://127.0.0.1:${CHROME_PORT}/json/version" >/dev/null 2>&1; then + echo "Chrome remote debugging did not start." >&2 + [[ -f "${CHROME_LOG}" ]] && cat "${CHROME_LOG}" >&2 + exit 1 +fi + +echo "Running Reader storybook long-route smoke verification..." +node "${ROOT_DIR}/scripts/verify_reader_storybook_long_route_smoke.js" \ + --url "${APP_URL}" \ + --seed-file "${SEED_FILE}" \ + --result-file "${RESULT_FILE}" \ + --history-file "${HISTORY_FILE}" \ + --failure-artifact-file "${FAILURE_ARTIFACT_FILE}" \ + --failure-screenshot-file "${FAILURE_SCREENSHOT_FILE}" \ + --storybook-screenshot-file "${STORYBOOK_SCREENSHOT_FILE}" \ + --chrome-port "${CHROME_PORT}" + +echo "Reader storybook long-route smoke passed." diff --git a/scripts/run_targeted_longform100_compare.py b/scripts/run_targeted_longform100_compare.py new file mode 100644 index 0000000..ccfacf1 --- /dev/null +++ b/scripts/run_targeted_longform100_compare.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, List, Sequence + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.narrativeos.benchmark.runner import run_benchmark +from src.narrativeos.repository import SQLAlchemyRepository +from src.narrativeos.services.training_signal import TrainingSignalService + + +DEFAULT_WEAKEST_THREE = [ + "xianxia_forgotten_vow", + "urban_mystery_lotus_lane", + "jade_court_exam", +] + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _load_summary(path: Path) -> Dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _target_worlds(args) -> List[str]: + explicit = [str(item).strip() for item in list(args.world_id or []) if str(item).strip()] + if explicit: + return explicit + if args.source_summary: + payload = _load_summary(Path(args.source_summary)) + weakest = [str(item.get("world_id") or "").strip() for item in list(payload.get("weakest_packs") or [])[:3] if str(item.get("world_id") or "").strip()] + if weakest: + return weakest + return list(DEFAULT_WEAKEST_THREE) + + +def _world_lookup(summary: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + return { + str(item.get("world_id") or ""): dict(item) + for item in list(summary.get("worlds") or []) + if str(item.get("world_id") or "").strip() + } + + +def _calibration_digest(payload: Dict[str, Any]) -> Dict[str, Any]: + calibration = dict(payload.get("continuation_calibration") or {}) + q03 = dict(calibration.get("q03") or {}) + q09 = dict(calibration.get("q09") or {}) + return { + "coverage_status": calibration.get("coverage_status"), + "sample_count": calibration.get("sample_count"), + "sample_gap": calibration.get("sample_gap"), + "q03_primary_metric": q03.get("primary_metric"), + "q03_primary_correlation": q03.get("primary_correlation"), + "q03_recommendation": q03.get("recommendation"), + "q09_primary_metric": q09.get("primary_metric"), + "q09_primary_correlation": q09.get("primary_correlation"), + "q09_recommendation": q09.get("recommendation"), + } + + +def build_targeted_compare_summary( + *, + before: Dict[str, Any], + after: Dict[str, Any], + supplementation: Dict[str, Any], + target_worlds: Sequence[str], +) -> Dict[str, Any]: + before_worlds = _world_lookup(before) + after_worlds = _world_lookup(after) + by_version_summary = { + str(item.get("world_version_id") or ""): dict(item) + for item in list(supplementation.get("world_summaries") or []) + if str(item.get("world_version_id") or "").strip() + } + deltas: List[Dict[str, Any]] = [] + for world_id in target_worlds: + before_item = before_worlds.get(world_id, {}) + after_item = after_worlds.get(world_id, {}) + supplement = by_version_summary.get(str(after_item.get("world_version_id") or ""), {}) + before_issue_map = {str(item.get("issue_code") or ""): int(item.get("count", 0) or 0) for item in list(before_item.get("top_issue_categories") or [])} + after_issue_map = {str(item.get("issue_code") or ""): int(item.get("count", 0) or 0) for item in list(after_item.get("top_issue_categories") or [])} + deltas.append( + { + "world_id": world_id, + "world_version_id": after_item.get("world_version_id") or before_item.get("world_version_id"), + "before_pass_rate": before_item.get("pass_rate"), + "after_pass_rate": after_item.get("pass_rate"), + "before_rewrite_rate": before_item.get("rewrite_rate"), + "after_rewrite_rate": after_item.get("rewrite_rate"), + "before_block_rate": before_item.get("block_rate"), + "after_block_rate": after_item.get("block_rate"), + "before_q03_count": before_issue_map.get("Q03", 0), + "after_q03_count": after_issue_map.get("Q03", 0), + "before_q09_count": before_issue_map.get("Q09", 0), + "after_q09_count": after_issue_map.get("Q09", 0), + "before_diagnostic_rank": before_item.get("diagnostic_rank"), + "after_diagnostic_rank": after_item.get("diagnostic_rank"), + "before_diagnostic_score": before_item.get("diagnostic_score"), + "after_diagnostic_score": after_item.get("diagnostic_score"), + "before_calibration": _calibration_digest(before_item), + "after_calibration": _calibration_digest(after_item), + "supplementation": supplement, + } + ) + return { + "generated_at": _utcnow(), + "target_worlds": list(target_worlds), + "before_summary_path": None, + "after_summary_path": None, + "supplementation": supplementation, + "world_deltas": deltas, + } + + +def _render_compare_markdown(summary: Dict[str, Any]) -> str: + lines = [ + "# Targeted Longform 100 Compare", + "", + "- generated_at: %s" % (summary.get("generated_at") or "-"), + "- target_worlds: %s" % (", ".join(summary.get("target_worlds", [])) or "-"), + "", + "## World Deltas", + ] + for item in summary.get("world_deltas", []): + lines.extend( + [ + "- %s" % item.get("world_id", "-"), + " pass %.3f -> %.3f · rewrite %.3f -> %.3f · block %.3f -> %.3f" + % ( + float(item.get("before_pass_rate", 0.0) or 0.0), + float(item.get("after_pass_rate", 0.0) or 0.0), + float(item.get("before_rewrite_rate", 0.0) or 0.0), + float(item.get("after_rewrite_rate", 0.0) or 0.0), + float(item.get("before_block_rate", 0.0) or 0.0), + float(item.get("after_block_rate", 0.0) or 0.0), + ), + " q03 %s -> %s · q09 %s -> %s" + % ( + item.get("before_q03_count", 0), + item.get("after_q03_count", 0), + item.get("before_q09_count", 0), + item.get("after_q09_count", 0), + ), + " calibration q03 %s -> %s · q09 %s -> %s" + % ( + dict(item.get("before_calibration") or {}).get("q03_recommendation", "-"), + dict(item.get("after_calibration") or {}).get("q03_recommendation", "-"), + dict(item.get("before_calibration") or {}).get("q09_recommendation", "-"), + dict(item.get("after_calibration") or {}).get("q09_recommendation", "-"), + ), + " continuation samples %s -> %s" + % ( + dict(item.get("before_calibration") or {}).get("sample_count", 0), + dict(item.get("after_calibration") or {}).get("sample_count", 0), + ), + ] + ) + return "\n".join(lines) + "\n" + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run weakest-three longform_100 compare with real continuation supplementation.") + parser.add_argument("--database-url", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--source-summary", default=None) + parser.add_argument("--world-id", action="append", default=[]) + parser.add_argument("--baseline-file", default="tests/benchmark_baseline.json") + parser.add_argument("--sample-target-per-version", type=int, default=8) + parser.add_argument("--negative-target-per-version", type=int, default=2) + parser.add_argument("--chapters-per-session", type=int, default=2) + parser.add_argument("--max-sessions-per-version", type=int, default=12) + args = parser.parse_args(list(argv) if argv is not None else None) + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + repository = SQLAlchemyRepository(database_url=args.database_url) + training_signal = TrainingSignalService(repository) + target_worlds = _target_worlds(args) + baseline_path = Path(args.baseline_file) + baseline = _load_summary(baseline_path) if baseline_path.exists() else None + + before = run_benchmark( + repository=repository, + golden_dir=output_dir / "before_goldens", + worldpack=target_worlds, + baseline=baseline, + benchmark_mode="longform_100", + max_chapters=100, + ) + world_version_ids = [str(item.get("world_version_id") or "") for item in list(before.get("worlds") or []) if str(item.get("world_version_id") or "").strip()] + supplementation = training_signal.supplement_real_continuation_samples( + world_version_ids=world_version_ids, + target_sample_count_per_version=int(args.sample_target_per_version), + target_negative_samples=int(args.negative_target_per_version), + chapters_per_session=int(args.chapters_per_session), + max_sessions_per_version=int(args.max_sessions_per_version), + reader_id_prefix="targeted_longform100", + ) + after = run_benchmark( + repository=repository, + golden_dir=output_dir / "after_goldens", + worldpack=target_worlds, + baseline=baseline, + benchmark_mode="longform_100", + max_chapters=100, + ) + compare = build_targeted_compare_summary( + before=before, + after=after, + supplementation=supplementation, + target_worlds=target_worlds, + ) + + before_path = output_dir / "targeted_longform100_before.json" + after_path = output_dir / "targeted_longform100_after.json" + compare_path = output_dir / "targeted_longform100_compare.json" + markdown_path = output_dir / "targeted_longform100_compare.md" + + before_path.write_text(json.dumps(before, ensure_ascii=False, indent=2), encoding="utf-8") + after_path.write_text(json.dumps(after, ensure_ascii=False, indent=2), encoding="utf-8") + compare["before_summary_path"] = str(before_path) + compare["after_summary_path"] = str(after_path) + compare_path.write_text(json.dumps(compare, ensure_ascii=False, indent=2), encoding="utf-8") + markdown_path.write_text(_render_compare_markdown(compare), encoding="utf-8") + print(json.dumps(compare, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/seed_reader_storybook_long_route_smoke.py b/scripts/seed_reader_storybook_long_route_smoke.py new file mode 100644 index 0000000..a753f89 --- /dev/null +++ b/scripts/seed_reader_storybook_long_route_smoke.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +from typing import Any, Dict, List, Sequence + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from src.narrativeos.api import create_app +from src.narrativeos.longform import apply_steering_directive +from src.narrativeos.models import NarrativeState, StepRecord +from src.narrativeos.pipeline import plan_next_turn +from src.narrativeos.providers import StaticCandidateProvider +from src.narrativeos.rendering import TemplateRenderer +from src.narrativeos.repository import SQLAlchemyRepository +from src.narrativeos.worldpacks.registry import FileSystemWorldRegistry + + +DEFAULT_ACCOUNT_ID = "reader_demo" +DEFAULT_READER_PASSWORD = "reader-smoke-pass-123" +DEFAULT_WORLD_IDS = ("jade_court_exam", "jade_court_romance", "urban_mystery_lotus_lane") +DEFAULT_TARGET_CHAPTERS = 30 +DEFAULT_MIN_TARGET_CHAPTERS = 30 +DEFAULT_MAX_ATTEMPTS_PER_CHAPTER = 4 +DEFAULT_REVIEW_TARGET_CHAPTERS = (1, 21, 220, 260, 460, 480) + + +def build_longform_setup(target_chapters: int) -> Dict[str, Any]: + return { + "series_storyline_contract": { + "core_storyline": f"主要关系、外部压力与未解承诺必须稳定推进到至少 {target_chapters} 章,不能中途仓促收尾。", + "protected_themes": ["关系债", "代价", "未解承诺", "继续读压力"], + }, + "steering_guardrails": { + "no_early_ending": True, + }, + } + + +def fallback_intent(chapter_target: int, retry_index: int) -> str: + intents = [ + f"我想把第 {chapter_target} 章继续往前推,但不要让局势提前收尾。", + f"先把第 {chapter_target} 章的动作和代价落地,再继续往下看。", + f"这一章先保留关系压力和未解承诺,不要急着解释或回收。", + f"先顺着局势继续推进,让余波和牵挂继续累积。", + ] + return intents[min(retry_index, len(intents) - 1)] + + +def retry_steering(chapter_target: int, retry_index: int) -> Dict[str, Any]: + return { + "steering_type": "mild_steer", + "current_user_intent": fallback_intent(chapter_target, retry_index), + "summary": f"第 {chapter_target} 章继续保留长路线张力,避免提前回收。", + } + + +def benchmark_world_ids() -> List[str]: + return [ + str(item["world_id"]) + for item in FileSystemWorldRegistry().list_benchmark_worldpacks() + if str(item.get("world_id") or "").strip() + ] + + +def resolve_world_ids(raw_world_ids: str) -> List[str]: + requested = [item.strip() for item in str(raw_world_ids or "").split(",") if item.strip()] + if not requested: + return [] + resolved: List[str] = [] + for item in requested: + if item.lower() == "all": + resolved.extend(benchmark_world_ids()) + continue + resolved.append(item) + deduped: List[str] = [] + seen = set() + for item in resolved: + if item in seen: + continue + seen.add(item) + deduped.append(item) + return deduped + + +def review_targets_for(target_chapters: int, requested_targets: Sequence[int]) -> List[int]: + targets = [int(item) for item in requested_targets if int(item) >= 1 and int(item) <= int(target_chapters)] + if targets: + return sorted(set(targets)) + fallback = sorted({1, max(1, min(target_chapters, target_chapters // 2)), target_chapters}) + return [item for item in fallback if item >= 1 and item <= target_chapters] + + +def build_seed_payload( + *, + database_url: str, + world_ids: List[str], + target_chapters: int, + min_target_chapters: int, + max_attempts_per_chapter: int, + review_target_chapters: Sequence[int] = DEFAULT_REVIEW_TARGET_CHAPTERS, +) -> Dict[str, Any]: + repository = SQLAlchemyRepository(database_url=database_url) + app = create_app(repository=repository) + + app.state.auth_service.register_identity( + actor_id=DEFAULT_ACCOUNT_ID, + actor_role="reader", + password=DEFAULT_READER_PASSWORD, + account_id=DEFAULT_ACCOUNT_ID, + display_name="Reader Smoke", + ) + + app.state.billing_service.grant_entitlement( + { + "account_id": DEFAULT_ACCOUNT_ID, + "reader_id": DEFAULT_ACCOUNT_ID, + "entitlement_type": "play_pass", + "provider": "smoke_seed", + "status": "active", + } + ) + + provider_routing_service = app.state.session_service.provider_routing + world_summaries: List[Dict[str, Any]] = [] + + for world_id in world_ids: + session_payload = app.state.session_service.create_session( + world_id, + reader_id=DEFAULT_ACCOUNT_ID, + longform_setup=build_longform_setup(target_chapters), + ) + session_id = str(session_payload["session_id"]) + world_version_id = str(session_payload["world_version_id"]) + runtime = repository.get_runtime_bundle(world_version_id) + state = NarrativeState.from_dict(repository.get_session(session_id).current_state.to_dict()) + access_snapshot = app.state.billing_service.access_check( + session_id, + reader_id=DEFAULT_ACCOUNT_ID, + account_id=DEFAULT_ACCOUNT_ID, + ) + + chapter_attempt_log: List[Dict[str, Any]] = [] + successful_chapters = 0 + reroute_retry_count = 0 + quote_nonempty_count = 0 + beats_nonempty_count = 0 + sampled_quotes: List[Dict[str, Any]] = [] + + while successful_chapters < target_chapters: + chapter_target = successful_chapters + 1 + chapter_completed = False + for attempt_index in range(max_attempts_per_chapter): + state_before = NarrativeState.from_dict(state.to_dict()) + intent = fallback_intent(chapter_target, attempt_index) + state_before.player_intent = app.state.session_service.intent_parser.parse(intent) + if attempt_index > 0: + apply_steering_directive( + state_before, + retry_steering(chapter_target, attempt_index), + world=runtime.world_record.world, + ) + reroute_retry_count += 1 + + candidate_provider = ( + provider_routing_service.build_candidate_provider( + runtime.event_atoms, + surface="reader", + account_id=DEFAULT_ACCOUNT_ID, + session_id=session_id, + world_id=runtime.worldpack.world_id, + world_version_id=runtime.world_version_id, + ) + if provider_routing_service + else StaticCandidateProvider(runtime.event_atoms) + ) + renderer = ( + provider_routing_service.build_renderer( + surface="reader", + account_id=DEFAULT_ACCOUNT_ID, + session_id=session_id, + world_id=runtime.worldpack.world_id, + world_version_id=runtime.world_version_id, + ) + if provider_routing_service + else TemplateRenderer() + ) + result = plan_next_turn( + state_before, + world=runtime.world_record.world, + candidate_provider=candidate_provider, + renderer=renderer, + candidate_reranker=None, + debug=True, + ) + status = str(result.get("status") or "ok") + + chapter_attempt_log.append( + { + "chapter_target": chapter_target, + "attempt": attempt_index + 1, + "status": status, + "intent": intent, + "chosen_event_title": dict(result.get("chosen_event") or {}).get("title"), + } + ) + + if status == "ok": + reader_view = dict(result.get("reader_view") or {}) + scene_card = dict(reader_view.get("scene_card") or {}) + quote_text = str(scene_card.get("quote") or "").strip() + beat_items = [str(item).strip() for item in list(scene_card.get("story_beats") or []) if str(item).strip()] + if quote_text: + quote_nonempty_count += 1 + if beat_items: + beats_nonempty_count += 1 + updated_state = NarrativeState.from_dict(result["updated_state"]) + step_record = StepRecord.from_dict( + { + "session_id": session_id, + "step_index": int(updated_state.chapter_index or chapter_target), + "player_input": intent, + "intent_vector": dict(state_before.player_intent), + "candidate_batch": result["candidate_batch"], + "scored_candidates": result["scored_candidates"], + "routes": result["routes"], + "chosen_event": result["chosen_event"], + "chapter_plan": result["chapter_plan"], + "scene_beats": result["scene_beats"], + "scene_render_spec": result["scene_render_spec"], + "rendered_scene": result["rendered_scene"], + "reader_view": result["reader_view"], + "state_before": state_before.to_dict(), + "state_after": updated_state.to_dict(), + "critic_trace": result["critic_trace"], + "promise_ledger_snapshot": [promise.to_dict() for promise in updated_state.open_promises], + "metadata": { + "access_tier": access_snapshot.get("access_tier"), + "seed_mode": "reader_storybook_long_route_smoke", + }, + } + ) + repository.save_step( + step_record, + world_version_id=world_version_id, + entitlements_snapshot=access_snapshot, + cost_estimate=round(max(1, len(reader_view.get("body") or "")) / 1200.0, 3), + ) + if len(sampled_quotes) < 3 or chapter_target in {1, min_target_chapters, target_chapters}: + sampled_quotes.append( + { + "chapter_index": int(reader_view.get("chapter_index") or chapter_target), + "chapter_title": str(reader_view.get("chapter_title") or ""), + "quote_length": len(quote_text), + "beat_count": len(beat_items), + } + ) + state = updated_state + successful_chapters = int(updated_state.chapter_index or chapter_target) + chapter_completed = True + break + + if status in {"quality_guard_failed", "payment_required", "restricted"}: + continue + + raise RuntimeError( + f"reader_long_route_unexpected_status: world={world_id} chapter={chapter_target} attempt={attempt_index + 1} status={status}" + ) + + if not chapter_completed: + raise RuntimeError( + "reader_long_route_seed_stalled: " + f"world={world_id} " + f"reached={successful_chapters} " + f"chapter_target={chapter_target} " + f"retries={max_attempts_per_chapter} " + f"log_tail={json.dumps(chapter_attempt_log[-8:], ensure_ascii=False)}" + ) + + replay_payload = repository.get_replay(session_id) + reader_views = list(replay_payload.get("reader_views") or []) + if len(reader_views) < min_target_chapters: + raise RuntimeError( + f"reader_long_route_seed_under_target: world={world_id} replay_views={len(reader_views)} min_target={min_target_chapters}" + ) + + latest_view = dict(reader_views[-1] or {}) if reader_views else {} + review_targets = review_targets_for(target_chapters, review_target_chapters) + visible_window = reader_views[-6:] + visible_sample_indexes = sorted({0, max(0, len(visible_window) // 2), max(0, len(visible_window) - 1)}) + world_summaries.append( + { + "world_id": world_id, + "world_version_id": world_version_id, + "session_id": session_id, + "target_chapters": target_chapters, + "min_target_chapters": min_target_chapters, + "reached_chapters": len(reader_views), + "quality_guard_retry_count": 0, + "payment_resume_count": 0, + "reroute_retry_count": reroute_retry_count, + "quote_coverage_rate": round(quote_nonempty_count / max(1, len(reader_views)), 3), + "beats_coverage_rate": round(beats_nonempty_count / max(1, len(reader_views)), 3), + "latest_chapter_title": str(latest_view.get("chapter_title") or ""), + "latest_chapter_index": int(latest_view.get("chapter_index") or len(reader_views)), + "review_target_chapters": review_targets, + "review_target_count": len(review_targets), + "review_target_windows": { + "early": [item for item in review_targets if 1 <= item <= 40], + "middle": [item for item in review_targets if 220 <= item <= 300], + "ending": [item for item in review_targets if 460 <= item <= 500], + }, + "visible_trajectory_chapter_indexes": [ + int((item or {}).get("chapter_index") or 0) for item in visible_window + ], + "visible_trajectory_sample_indexes": visible_sample_indexes, + "sampled_quotes": sampled_quotes[:6], + "chapter_attempt_log_tail": chapter_attempt_log[-12:], + } + ) + + return { + "account_id": DEFAULT_ACCOUNT_ID, + "reader_id": DEFAULT_ACCOUNT_ID, + "auth_actor_id": DEFAULT_ACCOUNT_ID, + "auth_password": DEFAULT_READER_PASSWORD, + "world_ids": list(world_ids), + "target_chapters": target_chapters, + "min_target_chapters": min_target_chapters, + "world_summaries": world_summaries, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Seed a multi-chapter Reader session for long-route storybook smoke verification." + ) + parser.add_argument("--database-url", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--world-ids", default=",".join(DEFAULT_WORLD_IDS)) + parser.add_argument("--target-chapters", type=int, default=DEFAULT_TARGET_CHAPTERS) + parser.add_argument("--min-target-chapters", type=int, default=DEFAULT_MIN_TARGET_CHAPTERS) + parser.add_argument("--max-attempts-per-chapter", type=int, default=DEFAULT_MAX_ATTEMPTS_PER_CHAPTER) + parser.add_argument("--review-target-chapters", default=",".join(str(item) for item in DEFAULT_REVIEW_TARGET_CHAPTERS)) + args = parser.parse_args() + world_ids = resolve_world_ids(str(args.world_ids or "")) + if not world_ids: + raise SystemExit("world_ids_required") + review_target_chapters = [ + int(item.strip()) + for item in str(args.review_target_chapters or "").split(",") + if item.strip() + ] + + payload = build_seed_payload( + database_url=args.database_url, + world_ids=world_ids, + target_chapters=int(args.target_chapters), + min_target_chapters=int(args.min_target_chapters), + max_attempts_per_chapter=int(args.max_attempts_per_chapter), + review_target_chapters=review_target_chapters, + ) + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(payload, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_ops_navigation_stale_ref_smoke.js b/scripts/verify_ops_navigation_stale_ref_smoke.js index fa1432b..b964e07 100644 --- a/scripts/verify_ops_navigation_stale_ref_smoke.js +++ b/scripts/verify_ops_navigation_stale_ref_smoke.js @@ -114,6 +114,22 @@ async function clickSelector(evaluate, selector) { })()`); } +async function enterOpsShellWithoutFullRefresh(evaluate) { + return evaluate(`(() => { + if (typeof appState === 'undefined') throw new Error('Missing appState'); + if (typeof syncProductMode !== 'function') throw new Error('Missing syncProductMode'); + appState.activeProduct = 'ops'; + syncProductMode(); + if (typeof renderOpsSurface === 'function') { + renderOpsSurface(); + } + return { + activeProduct: appState.activeProduct, + opsVisible: !document.querySelector('#ops-shell')?.classList.contains('is-hidden') + }; + })()`); +} + async function setValue(evaluate, selector, value) { const escapedSelector = JSON.stringify(selector); const escapedValue = JSON.stringify(value); @@ -270,7 +286,7 @@ async function main() { ); completeStep("wait_for_app_bootstrap"); markStep("enter_ops_mode"); - await clickSelector(evaluate, "#mode-ops"); + await enterOpsShellWithoutFullRefresh(evaluate); await waitFor( evaluate, "ops mode active", diff --git a/scripts/verify_reader_storybook_500_quantum.js b/scripts/verify_reader_storybook_500_quantum.js new file mode 100644 index 0000000..25a1605 --- /dev/null +++ b/scripts/verify_reader_storybook_500_quantum.js @@ -0,0 +1,499 @@ +const fs = require("fs"); +const http = require("http"); +const path = require("path"); + +const STORYBOOK_READY_TIMEOUT_MS = 300000; + +function parseArgs(argv) { + const result = {}; + for (let index = 0; index < argv.length; index += 1) { + const current = argv[index]; + if (!current.startsWith("--")) continue; + result[current.slice(2)] = argv[index + 1]; + index += 1; + } + return result; +} + +function readJsonFile(targetPath) { + return JSON.parse(fs.readFileSync(targetPath, "utf8")); +} + +function writeJsonFile(targetPath, payload) { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, JSON.stringify(payload, null, 2)); +} + +function httpJson({ method = "GET", hostname = "127.0.0.1", port, path }) { + return new Promise((resolve, reject) => { + const request = http.request({ method, hostname, port, path }, (response) => { + let data = ""; + response.on("data", (chunk) => { + data += chunk; + }); + response.on("end", () => { + try { + const payload = JSON.parse(data); + if (Number(response.statusCode || 0) >= 400) { + reject(new Error(`HTTP ${response.statusCode} ${path}: ${JSON.stringify(payload)}`)); + return; + } + resolve(payload); + } catch (_error) { + reject(new Error(`Failed to parse JSON from ${path}: ${data}`)); + } + }); + }); + request.on("error", reject); + request.end(); + }); +} + +async function openTarget(chromePort, url) { + return httpJson({ + method: "PUT", + port: chromePort, + path: `/json/new?${encodeURIComponent(url)}`, + }); +} + +async function postSeedLogin(apiUrl, seed) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 60000); + const response = await fetch(`${apiUrl}/api/v1/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + signal: controller.signal, + body: JSON.stringify({ + identifier: seed.auth_actor_id || seed.account_id, + password: seed.auth_password, + }), + }); + clearTimeout(timer); + const payload = await response.json(); + if (!response.ok || Number(payload.code || 0) !== 200 || !payload.data?.token) { + throw new Error(`seed_reader_login_failed:${response.status}:${JSON.stringify(payload)}`); + } + return payload.data; +} + +async function loginViaFrontendApi(url, seed) { + if (url !== "http://127.0.0.1:8000") { + try { + return await postSeedLogin("http://127.0.0.1:8000", seed); + } catch (_directError) { + return postSeedLogin(url, seed); + } + } + try { + return await postSeedLogin(url, seed); + } catch (error) { + if (url === "http://127.0.0.1:8000") throw error; + return postSeedLogin("http://127.0.0.1:8000", seed); + } +} + +async function connectToTarget(page) { + if (!page?.webSocketDebuggerUrl) throw new Error("Chrome target is missing a websocket debugger URL"); + const ws = new WebSocket(page.webSocketDebuggerUrl); + let id = 0; + const pending = new Map(); + const consoleErrors = []; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.id && pending.has(message.id)) { + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + if (message.error) reject(new Error(message.error.message)); + else resolve(message.result); + return; + } + if (message.method === "Runtime.exceptionThrown") { + consoleErrors.push({ + type: "exception", + text: + message.params?.exceptionDetails?.exception?.description || + message.params?.exceptionDetails?.text || + "Runtime.exceptionThrown", + }); + return; + } + if (message.method === "Runtime.consoleAPICalled" && message.params?.type === "error") { + consoleErrors.push({ + type: "console.error", + text: (message.params.args || []).map((item) => item.value || item.description || "").join(" ").trim(), + }); + } + }; + + ws.onclose = () => { + for (const { reject } of pending.values()) { + reject(new Error("Chrome target websocket closed")); + } + pending.clear(); + }; + + await new Promise((resolve) => { + ws.onopen = resolve; + }); + + const send = (method, params = {}, timeoutMs = 60000) => + new Promise((resolve, reject) => { + const current = ++id; + const timer = setTimeout(() => { + pending.delete(current); + reject(new Error(`Timed out waiting for Chrome command ${method}`)); + }, timeoutMs); + pending.set(current, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + ws.send(JSON.stringify({ id: current, method, params })); + }); + + const evaluate = async (expression) => { + const result = await send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result.result.subtype === "error") { + throw new Error(result.result.description || "Runtime evaluation failed"); + } + return result.result.value; + }; + + await send("Runtime.enable"); + await send("Page.enable"); + + return { ws, send, evaluate, consoleErrors }; +} + +async function sleep(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor(evaluate, label, expression, timeoutMs = 30000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await evaluate(`Boolean(${expression})`)) return; + await sleep(250); + } + throw new Error(`Timed out waiting for ${label}`); +} + +async function setValue(evaluate, selector, value) { + return evaluate(`(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) throw new Error('Missing selector: ' + ${JSON.stringify(selector)}); + const nextValue = ${JSON.stringify(value)}; + const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value'); + if (descriptor && typeof descriptor.set === 'function') { + descriptor.set.call(el, nextValue); + } else { + el.value = nextValue; + } + el.focus(); + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: nextValue })); + el.dispatchEvent(new Event('change', { bubbles: true })); + if (el.value !== nextValue) throw new Error('Failed to set selector value: ' + ${JSON.stringify(selector)}); + return el.value; + })()`); +} + +async function clickByText(evaluate, selector, label) { + return evaluate(`(() => { + const button = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).find((item) => item.textContent.trim() === ${JSON.stringify(label)}); + if (!button) throw new Error('Missing button: ' + ${JSON.stringify(label)}); + button.click(); + return true; + })()`); +} + +async function captureScreenshot(send, targetPath) { + const result = await send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + }); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, Buffer.from(result.data, "base64")); + return targetPath; +} + +function targetWindowFor(chapterIndex) { + if (chapterIndex >= 1 && chapterIndex <= 40) return "early"; + if (chapterIndex >= 220 && chapterIndex <= 300) return "middle"; + if (chapterIndex >= 460 && chapterIndex <= 500) return "ending"; + return "recent"; +} + +async function ensureLoggedIn(evaluate, seed) { + await waitFor( + evaluate, + "Quantum story page or auth recovery", + `document.querySelector('#reader-v2-storybook-title') || document.body.innerText.includes('登录后继续阅读')`, + STORYBOOK_READY_TIMEOUT_MS + ); + const needsLogin = await evaluate(`document.body.innerText.includes('登录后继续阅读')`); + if (!needsLogin) { + await waitFor( + evaluate, + "authenticated Quantum Storybook", + `document.querySelector('#reader-v2-storybook-title') && (document.querySelector('#reader-v2-storybook-prose')?.innerText || '').trim().length > 400`, + STORYBOOK_READY_TIMEOUT_MS + ); + return; + } + await clickByText(evaluate, "button", "登录后继续"); + await waitFor(evaluate, "auth modal", `document.querySelector('#quantum-auth-identifier') && document.querySelector('#quantum-auth-password')`, 15000); + await setValue(evaluate, "#quantum-auth-identifier", seed.auth_actor_id || seed.account_id); + await setValue(evaluate, "#quantum-auth-password", seed.auth_password); + await clickByText(evaluate, "button", "即刻降临"); + await waitFor(evaluate, "stored Quantum auth token", `Boolean(localStorage.getItem('qi_token'))`, 15000); + await evaluate(`location.reload()`); + await waitFor( + evaluate, + "authenticated Quantum Storybook", + `document.querySelector('#reader-v2-storybook-title') && (document.querySelector('#reader-v2-storybook-prose')?.innerText || '').trim().length > 400`, + STORYBOOK_READY_TIMEOUT_MS + ); +} + +async function seedBrowserAuth(evaluate, authPayload) { + await evaluate(`(() => { + localStorage.setItem('qi_token', ${JSON.stringify(authPayload.token)}); + localStorage.setItem('qi_refresh', ${JSON.stringify(authPayload.refreshToken || '')}); + window.dispatchEvent(new Event('qi-auth-changed')); + return true; + })()`); +} + +async function seedBrowserAuthBeforeNavigation(send, authPayload) { + await send("Page.addScriptToEvaluateOnNewDocument", { + source: ` + (() => { + try { + localStorage.setItem('qi_token', ${JSON.stringify(authPayload.token)}); + localStorage.setItem('qi_refresh', ${JSON.stringify(authPayload.refreshToken || '')}); + } catch (_error) {} + })(); + `, + }); +} + +async function selectStorybookChapter(evaluate, chapterIndex) { + const windowKey = targetWindowFor(chapterIndex); + await evaluate(`(() => { + const tab = document.querySelector('[data-reader-storybook-window="${windowKey}"]'); + if (!tab) throw new Error('Missing storybook window tab: ${windowKey}'); + if (tab.disabled) throw new Error('Disabled storybook window tab: ${windowKey}'); + tab.click(); + return true; + })()`); + await waitFor( + evaluate, + `storybook window ${windowKey}`, + `document.querySelector('[data-reader-storybook-window="${windowKey}"]') && document.querySelector('#reader-v2-storybook-sequence .reader-shell-v2__trajectory-card')`, + 15000 + ); + await evaluate(`(() => { + const cards = Array.from(document.querySelectorAll('#reader-v2-storybook-sequence .reader-shell-v2__trajectory-card')); + const card = cards.find((item) => (item.innerText || '').includes('Chapter ${Number(chapterIndex)}')); + if (!card) throw new Error('Missing trajectory card for chapter ${Number(chapterIndex)}'); + card.click(); + return true; + })()`); + await waitFor( + evaluate, + `storybook chapter ${chapterIndex}`, + `(() => { + const active = document.querySelector('#reader-v2-storybook-sequence .reader-shell-v2__trajectory-card.is-active'); + return Boolean(active && (active.innerText || '').includes('Chapter ${Number(chapterIndex)}') && (document.querySelector('#reader-v2-storybook-title')?.innerText || '').trim().length > 0); + })()`, + 15000 + ); +} + +async function chapterSnapshot(evaluate) { + return evaluate(`(() => { + const quote = (document.querySelector('#reader-v2-storybook-quote')?.innerText || '').trim(); + const beats = Array.from(document.querySelectorAll('#reader-v2-storybook-beats .reader-shell-v2__beat-copy')).map((item) => item.innerText.trim()).filter(Boolean); + const activeCard = document.querySelector('#reader-v2-storybook-sequence .reader-shell-v2__trajectory-card.is-active'); + return { + title: (document.querySelector('#reader-v2-storybook-title')?.innerText || '').trim(), + prose_length: (document.querySelector('#reader-v2-storybook-prose')?.innerText || '').trim().length, + quote, + quote_length: quote.length, + quote_placeholder: quote.includes('这里会显示') || quote.includes('未记录'), + beat_count: beats.length, + active_card_text: activeCard?.innerText || '', + url: location.href, + }; + })()`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const url = String(args.url || "http://127.0.0.1:3000").replace(/\/$/, ""); + const chromePort = Number(args["chrome-port"] || 9225); + const seedFile = args["seed-file"]; + const resultFile = args["result-file"]; + const failureArtifactFile = args["failure-artifact-file"]; + const screenshotDir = args["screenshot-dir"]; + if (!seedFile || !resultFile || !failureArtifactFile || !screenshotDir) { + throw new Error("Usage: node verify_reader_storybook_500_quantum.js --url --seed-file --result-file --failure-artifact-file --screenshot-dir [--chrome-port ]"); + } + + const seed = readJsonFile(seedFile); + const firstSession = seed.world_summaries?.[0]?.session_id; + if (!firstSession) throw new Error("seed_missing_world_summaries"); + const authPayload = await loginViaFrontendApi(url, seed); + + const completedSteps = []; + let currentStep = "bootstrap"; + let activePage = null; + const allConsoleErrors = []; + const mark = (step) => { + currentStep = step; + }; + const complete = (step) => { + completedSteps.push(step); + }; + + try { + mark("login"); + complete("login"); + + const worldResults = []; + for (const [worldIndex, worldSummary] of (seed.world_summaries || []).entries()) { + mark(`open:${worldSummary.world_id}`); + const storyUrl = `${url}/story?session=${encodeURIComponent(worldSummary.session_id)}&reader_verify=${worldIndex + 1}`; + const target = await openTarget(chromePort, "about:blank"); + activePage = await connectToTarget(target); + const { ws, send, evaluate, consoleErrors } = activePage; + await seedBrowserAuthBeforeNavigation(send, authPayload); + await send("Page.navigate", { url: storyUrl }); + await waitFor(evaluate, `Quantum Story URL ${worldSummary.world_id}`, `location.href === ${JSON.stringify(storyUrl)}`, 15000); + await ensureLoggedIn(evaluate, seed); + const storyReadyExpression = `document.querySelector('#reader-v2-storybook-title') && (document.querySelector('#reader-v2-storybook-prose')?.innerText || '').trim().length > 400`; + try { + await waitFor( + evaluate, + `Quantum Story session ${worldSummary.world_id}`, + storyReadyExpression, + STORYBOOK_READY_TIMEOUT_MS + ); + } catch (error) { + const retryUrl = `${storyUrl}&reader_verify_retry=1`; + await send("Page.navigate", { url: retryUrl }); + await waitFor(evaluate, `Quantum Story retry URL ${worldSummary.world_id}`, `location.href === ${JSON.stringify(retryUrl)}`, 15000); + await waitFor( + evaluate, + `Quantum Story session retry ${worldSummary.world_id}`, + storyReadyExpression, + STORYBOOK_READY_TIMEOUT_MS + ); + } + complete(`open:${worldSummary.world_id}`); + + const chapterResults = []; + for (const chapterIndex of worldSummary.review_target_chapters || []) { + mark(`chapter:${worldSummary.world_id}:${chapterIndex}`); + await selectStorybookChapter(evaluate, Number(chapterIndex)); + const snapshot = await chapterSnapshot(evaluate); + if (!snapshot.title) throw new Error(`reader_storybook_title_missing:${worldSummary.world_id}:${chapterIndex}`); + if (snapshot.prose_length <= 400) throw new Error(`reader_storybook_prose_too_short:${worldSummary.world_id}:${chapterIndex}:${snapshot.prose_length}`); + if (snapshot.quote_length < 8 || snapshot.quote_placeholder) throw new Error(`reader_storybook_quote_invalid:${worldSummary.world_id}:${chapterIndex}`); + if (snapshot.beat_count < 1) throw new Error(`reader_storybook_beats_missing:${worldSummary.world_id}:${chapterIndex}`); + if (!String(snapshot.active_card_text || "").includes(`Chapter ${Number(chapterIndex)}`)) { + throw new Error(`reader_storybook_active_card_mismatch:${worldSummary.world_id}:${chapterIndex}`); + } + chapterResults.push({ + chapter_index: Number(chapterIndex), + title: snapshot.title, + prose_length: snapshot.prose_length, + quote_length: snapshot.quote_length, + beat_count: snapshot.beat_count, + }); + complete(`chapter:${worldSummary.world_id}:${chapterIndex}`); + } + const screenshotPath = path.join(screenshotDir, `${worldSummary.world_id}.png`); + await captureScreenshot(send, screenshotPath); + allConsoleErrors.push(...consoleErrors.map((item) => ({ ...item, world_id: worldSummary.world_id }))); + await send("Page.close", {}, 5000).catch(() => {}); + ws.close(); + activePage = null; + worldResults.push({ + world_id: worldSummary.world_id, + session_id: worldSummary.session_id, + reached_chapters: worldSummary.reached_chapters, + sampled_chapter_count: chapterResults.length, + screenshot_file: screenshotPath, + chapters: chapterResults, + }); + } + + if (allConsoleErrors.length) { + throw new Error(`Quantum Storybook emitted ${allConsoleErrors.length} console error(s)`); + } + + const reviewedCount = worldResults.reduce((total, item) => total + item.sampled_chapter_count, 0); + const resultPayload = { + status: "ok", + schema_version: "reader_storybook_500_quantum_result/v1", + app_url: url, + seed_file: seedFile, + target_count: (seed.world_summaries || []).reduce((total, item) => total + (item.review_target_chapters || []).length, 0), + reviewed_count: reviewedCount, + world_count: worldResults.length, + worlds_reaching_500: (seed.world_summaries || []).filter((item) => Number(item.reached_chapters || 0) >= 500).length, + completed_steps: completedSteps, + console_errors: allConsoleErrors, + world_results: worldResults, + screenshot_dir: screenshotDir, + }; + writeJsonFile(resultFile, resultPayload); + console.log(JSON.stringify(resultPayload, null, 2)); + } catch (error) { + const evaluate = activePage?.evaluate; + const snapshot = evaluate ? await evaluate(`({ + title: document.title || '', + url: location.href || '', + body_text_excerpt: (document.body?.innerText || '').slice(0, 5000), + storybook_title: document.querySelector('#reader-v2-storybook-title')?.innerText || '', + storybook_prose_length: (document.querySelector('#reader-v2-storybook-prose')?.innerText || '').trim().length, + trajectory_text: Array.from(document.querySelectorAll('#reader-v2-storybook-sequence .reader-shell-v2__trajectory-card')).map((item) => item.innerText), + })`).catch((snapshotError) => ({ snapshot_error: snapshotError.message || String(snapshotError) })) : { snapshot_error: "no_active_chrome_target" }; + const failurePayload = { + status: "error", + app_url: url, + seed_file: seedFile, + completed_steps: completedSteps, + failed_step: currentStep, + error_message: error && error.message ? error.message : String(error), + snapshot, + console_errors: allConsoleErrors.concat(activePage?.consoleErrors || []), + }; + writeJsonFile(failureArtifactFile, failurePayload); + writeJsonFile(resultFile, failurePayload); + throw error; + } finally { + if (activePage?.ws) activePage.ws.close(); + } +} + +main().catch((error) => { + console.error("READER_STORYBOOK_500_QUANTUM_ERROR"); + console.error(error && error.stack ? error.stack : error); + process.exit(1); +}); diff --git a/scripts/verify_reader_storybook_long_route_smoke.js b/scripts/verify_reader_storybook_long_route_smoke.js new file mode 100644 index 0000000..c52daf5 --- /dev/null +++ b/scripts/verify_reader_storybook_long_route_smoke.js @@ -0,0 +1,843 @@ +const fs = require("fs"); +const http = require("http"); +const path = require("path"); + +const READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_SCHEMA_VERSION = + "reader_storybook_title_homogenization_history/v1"; +const READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_LIMIT = 20; +const READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD = 3; +const TITLE_HOMOGENIZATION_WARNING_KIND = "title_homogenization_non_blocking"; + +function parseArgs(argv) { + const result = {}; + for (let index = 0; index < argv.length; index += 1) { + const current = argv[index]; + if (!current.startsWith("--")) continue; + const key = current.slice(2); + result[key] = argv[index + 1]; + index += 1; + } + return result; +} + +function readJsonFile(targetPath) { + if (!targetPath || !fs.existsSync(targetPath)) return {}; + try { + return JSON.parse(fs.readFileSync(targetPath, "utf8")); + } catch (_error) { + return {}; + } +} + +function writeJsonFile(targetPath, payload) { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, JSON.stringify(payload, null, 2)); +} + +function httpJson({ method = "GET", hostname = "127.0.0.1", port, path }) { + return new Promise((resolve, reject) => { + const request = http.request({ method, hostname, port, path }, (response) => { + let data = ""; + response.on("data", (chunk) => { + data += chunk; + }); + response.on("end", () => { + try { + const payload = JSON.parse(data); + if (Number(response.statusCode || 0) >= 400) { + reject(new Error(`HTTP ${response.statusCode} ${path}: ${JSON.stringify(payload)}`)); + return; + } + resolve(payload); + } catch (_error) { + reject(new Error(`Failed to parse JSON from ${path}: ${data}`)); + } + }); + }); + request.on("error", reject); + request.end(); + }); +} + +async function openAppTarget(chromePort, url) { + return httpJson({ + method: "PUT", + port: chromePort, + path: `/json/new?${encodeURIComponent(url)}`, + }); +} + +async function connectToPage(pageUrl, chromePort) { + const targets = await httpJson({ port: chromePort, path: "/json/list" }); + const targetUrl = new URL(pageUrl); + const page = + targets.find((item) => item.url === pageUrl) || + targets.find((item) => { + try { + const candidate = new URL(item.url); + return candidate.origin === targetUrl.origin && candidate.pathname === targetUrl.pathname; + } catch (_error) { + return false; + } + }); + if (!page) { + throw new Error(`App page target not found for ${pageUrl}`); + } + const ws = new WebSocket(page.webSocketDebuggerUrl); + let id = 0; + const pending = new Map(); + const consoleErrors = []; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.id && pending.has(message.id)) { + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + if (message.error) reject(new Error(message.error.message)); + else resolve(message.result); + return; + } + if (message.method === "Runtime.exceptionThrown") { + consoleErrors.push({ + type: "exception", + text: + message.params?.exceptionDetails?.exception?.description || + message.params?.exceptionDetails?.text || + "Runtime.exceptionThrown", + }); + return; + } + if (message.method === "Runtime.consoleAPICalled" && message.params?.type === "error") { + consoleErrors.push({ + type: "console.error", + text: (message.params.args || []).map((item) => item.value || item.description || "").join(" ").trim(), + }); + } + }; + + await new Promise((resolve) => { + ws.onopen = resolve; + }); + + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const current = ++id; + pending.set(current, { resolve, reject }); + ws.send(JSON.stringify({ id: current, method, params })); + }); + + const evaluate = async (expression) => { + const result = await send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result.result.subtype === "error") { + throw new Error(result.result.description || "Runtime evaluation failed"); + } + return result.result.value; + }; + + await send("Runtime.enable"); + await send("Page.enable"); + + return { ws, send, evaluate, consoleErrors }; +} + +async function sleep(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor(evaluate, label, expression, timeoutMs = 15000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const ready = await evaluate(`Boolean(${expression})`); + if (ready) return; + await sleep(250); + } + throw new Error(`Timed out waiting for ${label}`); +} + +function normalizeSignatureText(value) { + return String(value || "") + .replace(/第\s*\d+\s*章/g, " ") + .replace(/[^\w\u4e00-\u9fff]+/g, "") + .toLowerCase(); +} + +function bigramSet(value) { + const normalized = normalizeSignatureText(value); + if (!normalized) return new Set(); + if (normalized.length < 2) return new Set([normalized]); + const grams = new Set(); + for (let index = 0; index < normalized.length - 1; index += 1) { + grams.add(normalized.slice(index, index + 2)); + } + return grams; +} + +function jaccardSimilarity(left, right) { + if (!left.size || !right.size) return 0; + let intersection = 0; + for (const item of left) { + if (right.has(item)) intersection += 1; + } + return intersection / (left.size + right.size - intersection); +} + +function normalizeHistoryEntry(entry) { + return { + generated_at: String(entry?.generated_at || "").trim(), + world_ids: Array.from( + new Set((entry?.world_ids || []).map((item) => String(item || "").trim()).filter(Boolean)) + ), + cross_pack_distinctness: (entry?.cross_pack_distinctness || []) + .map((item) => ({ + non_jade_world_id: String(item?.non_jade_world_id || "").trim(), + jade_world_id: String(item?.jade_world_id || "").trim(), + title_similarity: Number(Number(item?.title_similarity || 0).toFixed(3)), + quote_similarity: Number(Number(item?.quote_similarity || 0).toFixed(3)), + passes_min_difference: Boolean(item?.passes_min_difference), + })) + .filter((item) => item.non_jade_world_id && item.jade_world_id), + title_homogenization_warnings: (entry?.title_homogenization_warnings || []) + .map((item) => ({ + non_jade_world_id: String(item?.non_jade_world_id || "").trim(), + jade_world_id: String(item?.jade_world_id || "").trim(), + title_similarity: Number(Number(item?.title_similarity || 0).toFixed(3)), + quote_similarity: Number(Number(item?.quote_similarity || 0).toFixed(3)), + warning_kind: String(item?.warning_kind || TITLE_HOMOGENIZATION_WARNING_KIND).trim(), + message: String(item?.message || "").trim(), + })) + .filter((item) => item.non_jade_world_id && item.jade_world_id), + }; +} + +function normalizeHistoryPayload(payload) { + const entries = (payload?.entries || []) + .map((item) => normalizeHistoryEntry(item)) + .filter((item) => item.generated_at) + .sort((left, right) => String(right.generated_at || "").localeCompare(String(left.generated_at || ""))) + .slice(0, READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_LIMIT); + return { + schema_version: READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_SCHEMA_VERSION, + available: entries.length > 0, + history_limit: READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_LIMIT, + promotion_threshold: READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD, + entry_count: entries.length, + latest_generated_at: entries[0]?.generated_at || null, + entries, + }; +} + +function appendHistoryEntry(historyPayload, entry) { + return normalizeHistoryPayload({ + entries: [normalizeHistoryEntry(entry), ...((historyPayload?.entries || []).map((item) => normalizeHistoryEntry(item)))] + .filter((item) => item.generated_at), + }); +} + +function historyEntryIncludesPair(entry, pair) { + const worldIds = new Set((entry?.world_ids || []).map((item) => String(item || "").trim())); + return worldIds.has(pair.non_jade_world_id) && worldIds.has(pair.jade_world_id); +} + +function historyEntryWarningForPair(entry, pair) { + return (entry?.title_homogenization_warnings || []).find( + (item) => + item.non_jade_world_id === pair.non_jade_world_id && + item.jade_world_id === pair.jade_world_id && + item.warning_kind === TITLE_HOMOGENIZATION_WARNING_KIND + ) || null; +} + +function historyEntryDistinctnessForPair(entry, pair) { + return (entry?.cross_pack_distinctness || []).find( + (item) => + item.non_jade_world_id === pair.non_jade_world_id && + item.jade_world_id === pair.jade_world_id + ) || null; +} + +function buildTitleHomogenizationTrend(historyPayload) { + const normalizedHistory = normalizeHistoryPayload(historyPayload); + const pairMap = new Map(); + for (const entry of normalizedHistory.entries) { + for (const item of entry.cross_pack_distinctness || []) { + pairMap.set(`${item.non_jade_world_id}::${item.jade_world_id}`, { + non_jade_world_id: item.non_jade_world_id, + jade_world_id: item.jade_world_id, + }); + } + for (const item of entry.title_homogenization_warnings || []) { + pairMap.set(`${item.non_jade_world_id}::${item.jade_world_id}`, { + non_jade_world_id: item.non_jade_world_id, + jade_world_id: item.jade_world_id, + }); + } + } + const pairTrends = []; + for (const pair of Array.from(pairMap.values()).sort((left, right) => `${left.non_jade_world_id}:${left.jade_world_id}`.localeCompare(`${right.non_jade_world_id}:${right.jade_world_id}`))) { + let consecutiveWarningCount = 0; + let eligibleRunCount = 0; + let latestSeenAt = null; + let latestDistinctness = null; + for (const entry of normalizedHistory.entries) { + const distinctness = historyEntryDistinctnessForPair(entry, pair); + if (!latestDistinctness && distinctness) { + latestDistinctness = distinctness; + } + if (!historyEntryIncludesPair(entry, pair)) { + continue; + } + eligibleRunCount += 1; + const warning = historyEntryWarningForPair(entry, pair); + if (warning) { + consecutiveWarningCount += 1; + if (!latestSeenAt) { + latestSeenAt = entry.generated_at || null; + } + continue; + } + break; + } + const promotedToReleaseReview = + consecutiveWarningCount >= READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD; + pairTrends.push({ + non_jade_world_id: pair.non_jade_world_id, + jade_world_id: pair.jade_world_id, + eligible_run_count: eligibleRunCount, + consecutive_warning_count: consecutiveWarningCount, + latest_seen_at: latestSeenAt, + latest_title_similarity: Number(Number(latestDistinctness?.title_similarity || 0).toFixed(3)), + latest_quote_similarity: Number(Number(latestDistinctness?.quote_similarity || 0).toFixed(3)), + trend_status: promotedToReleaseReview + ? "promoted" + : consecutiveWarningCount > 0 + ? "watch" + : "clear", + promoted_to_release_review: promotedToReleaseReview, + }); + } + const promotedPairs = pairTrends.filter((item) => item.promoted_to_release_review); + let trendStatus = "no_history"; + let trendReason = "no_reader_storybook_smoke_history"; + if (normalizedHistory.entry_count > 0) { + if (promotedPairs.length) { + trendStatus = "promoted_pairs_present"; + trendReason = "title_homogenization_promotion_threshold_met"; + } else if (pairTrends.some((item) => item.consecutive_warning_count > 0)) { + trendStatus = "watch"; + trendReason = "title_homogenization_warning_streak_below_threshold"; + } else { + trendStatus = "clear"; + trendReason = "no_active_title_homogenization_warning_streaks"; + } + } + return { + available: normalizedHistory.entry_count > 0, + entry_count: normalizedHistory.entry_count, + latest_generated_at: normalizedHistory.latest_generated_at, + threshold: READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD, + trend_status: trendStatus, + trend_reason: trendReason, + promoted_pair_count: promotedPairs.length, + promoted_pairs: promotedPairs, + pair_trends: pairTrends, + }; +} + +function summarizeTitleHomogenizationHistory(historyPayload, trendPayload) { + const normalizedHistory = normalizeHistoryPayload(historyPayload); + const trend = trendPayload || buildTitleHomogenizationTrend(normalizedHistory); + return { + available: normalizedHistory.entry_count > 0, + entry_count: normalizedHistory.entry_count, + latest_generated_at: normalizedHistory.latest_generated_at, + threshold: READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD, + trend_status: trend.trend_status, + trend_reason: trend.trend_reason, + promoted_pair_count: trend.promoted_pair_count || 0, + }; +} + +function crossPackDistinctnessComparisons(worldSummaries) { + const jadeSummaries = (worldSummaries || []).filter((item) => String(item.world_id || "").startsWith("jade_court_")); + const nonJadeSummaries = (worldSummaries || []).filter((item) => !String(item.world_id || "").startsWith("jade_court_")); + const comparisons = []; + for (const nonJade of nonJadeSummaries) { + for (const jade of jadeSummaries) { + const titleSimilarity = jaccardSimilarity( + bigramSet((nonJade.sampled_titles || []).join(" ")), + bigramSet((jade.sampled_titles || []).join(" ")) + ); + const quoteSimilarity = jaccardSimilarity( + bigramSet((nonJade.sampled_quotes || []).join(" ")), + bigramSet((jade.sampled_quotes || []).join(" ")) + ); + comparisons.push({ + non_jade_world_id: nonJade.world_id, + jade_world_id: jade.world_id, + title_similarity: Number(titleSimilarity.toFixed(3)), + quote_similarity: Number(quoteSimilarity.toFixed(3)), + passes_min_difference: !(titleSimilarity >= 0.75 && quoteSimilarity >= 0.35), + }); + } + } + return comparisons; +} + +function titleHomogenizationWarnings(distinctnessComparisons) { + return (distinctnessComparisons || []) + .filter((item) => item.title_similarity >= 0.95 && item.quote_similarity < 0.35) + .map((item) => ({ + non_jade_world_id: item.non_jade_world_id, + jade_world_id: item.jade_world_id, + title_similarity: item.title_similarity, + quote_similarity: item.quote_similarity, + warning_kind: "title_homogenization_non_blocking", + message: "sampled titles are highly similar across packs, but quote-token overlap remains below the blocking threshold.", + })); +} + +async function clickButtonByText(evaluate, selector, label) { + const escapedSelector = JSON.stringify(selector); + const escapedLabel = JSON.stringify(label); + return evaluate(`(() => { + const button = Array.from(document.querySelectorAll(${escapedSelector})).find((item) => item.textContent.trim() === ${escapedLabel}); + if (!button) throw new Error('Missing button: ' + ${escapedLabel}); + button.click(); + return true; + })()`); +} + +async function setValue(evaluate, selector, value) { + const escapedSelector = JSON.stringify(selector); + const escapedValue = JSON.stringify(value); + return evaluate(`(() => { + const el = document.querySelector(${escapedSelector}); + if (!el) throw new Error('Missing selector: ' + ${escapedSelector}); + el.value = ${escapedValue}; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + return el.value; + })()`); +} + +async function clickTrajectoryCard(evaluate, visibleIndex) { + return evaluate(`(() => { + const cards = Array.from(document.querySelectorAll('#reader-v2-storybook-sequence .reader-shell-v2__trajectory-card')); + const card = cards[${Number(visibleIndex)}]; + if (!card) throw new Error('Missing trajectory card at visible index ${Number(visibleIndex)}'); + card.click(); + return { + title: card.querySelector('strong')?.innerText || '', + action: card.dataset.readerV2Action || '', + }; + })()`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const url = args.url; + const chromePort = Number(args["chrome-port"] || 9225); + const seedFile = args["seed-file"]; + const resultFile = args["result-file"]; + const failureArtifactFile = args["failure-artifact-file"]; + const failureScreenshotFile = args["failure-screenshot-file"]; + const storybookScreenshotFile = args["storybook-screenshot-file"]; + const historyFile = args["history-file"]; + if (!url || !seedFile || !resultFile || !failureArtifactFile || !failureScreenshotFile || !storybookScreenshotFile || !historyFile) { + throw new Error( + "Usage: node verify_reader_storybook_long_route_smoke.js --url --seed-file --result-file --failure-artifact-file --failure-screenshot-file --storybook-screenshot-file --history-file [--chrome-port ]" + ); + } + + const writeResult = (payload) => { + fs.mkdirSync(path.dirname(resultFile), { recursive: true }); + fs.writeFileSync(resultFile, JSON.stringify(payload, null, 2)); + }; + const writeFailureArtifact = (payload) => { + fs.mkdirSync(path.dirname(failureArtifactFile), { recursive: true }); + fs.writeFileSync(failureArtifactFile, JSON.stringify(payload, null, 2)); + }; + const writePng = (targetPath, base64Png) => { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, Buffer.from(base64Png, "base64")); + }; + + const stepOrder = []; + let currentStep = "bootstrap"; + const markStep = (step) => { + currentStep = step; + }; + const completeStep = (step) => { + stepOrder.push(step); + }; + + const seed = JSON.parse(fs.readFileSync(seedFile, "utf8")); + await openAppTarget(chromePort, url); + await sleep(1000); + const { ws, send, evaluate, consoleErrors } = await connectToPage(url, chromePort); + + const captureFailureSnapshot = async () => { + try { + return await evaluate(`({ + title: document.title || '', + url: location.href || '', + session_id: String(readerState?.sessionId || ''), + active_view: readerState?.activeView || '', + selected_replay_index: Number(readerState?.selectedReplayIndex ?? -1), + storybook_title: document.querySelector('#reader-v2-storybook-title')?.innerText || '', + storybook_quote: document.querySelector('#reader-v2-storybook-quote')?.innerText || '', + storybook_beats: Array.from(document.querySelectorAll('#reader-v2-storybook-beats .reader-shell-v2__beat-copy')).map((node) => node.innerText.trim()), + trajectory_titles: Array.from(document.querySelectorAll('#reader-v2-storybook-sequence .reader-shell-v2__trajectory-card strong')).map((node) => node.innerText.trim()), + body_text_excerpt: (document.body?.innerText || '').slice(0, 4000), + body_html_excerpt: (document.body?.innerHTML || '').slice(0, 12000) + })`); + } catch (error) { + return { snapshot_error: error && error.message ? error.message : String(error) }; + } + }; + + const captureScreenshot = async (targetPath) => { + const result = await send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + }); + writePng(targetPath, result.data); + return targetPath; + }; + + async function captureWorldStorybook(worldSummary) { + markStep(`restore_seeded_session:${worldSummary.world_id}`); + await evaluate(`(async () => { + await ReaderRuntime.restoreSession(${JSON.stringify(worldSummary.session_id)}); + return true; + })()`); + await waitFor( + evaluate, + `reader restored seeded session ${worldSummary.world_id}`, + `String(readerState.sessionId || '') === ${JSON.stringify(worldSummary.session_id)} + && new URL(location.href).searchParams.get('workspace') === 'read' + && document.querySelector('#reader-v2-read-hero')`, + 30000 + ); + completeStep(`restore_seeded_session:${worldSummary.world_id}`); + + markStep(`open_storybook:${worldSummary.world_id}`); + await clickButtonByText(evaluate, "#product-subnav-actions button", "图文阅读"); + await waitFor( + evaluate, + `reader storybook long route view ${worldSummary.world_id}`, + `readerState.activeView === 'storybook' + && new URL(location.href).searchParams.get('view') === 'storybook' + && document.querySelector('#reader-v2-storybook') + && (document.querySelector('#reader-v2-storybook-prose')?.innerText || '').trim().length > 400`, + 30000 + ); + completeStep(`open_storybook:${worldSummary.world_id}`); + + markStep(`verify_trajectory_window:${worldSummary.world_id}`); + const trajectorySnapshot = await evaluate(`(() => { + const cards = Array.from(document.querySelectorAll('#reader-v2-storybook-sequence .reader-shell-v2__trajectory-card')); + return { + count: cards.length, + active_count: cards.filter((card) => card.classList.contains('is-active')).length, + titles: cards.map((card) => card.querySelector('strong')?.innerText || ''), + actions: cards.map((card) => card.dataset.readerV2Action || ''), + }; + })()`); + const expectedVisibleCount = Math.min(Number(worldSummary.reached_chapters || 0), 6); + if (Number(trajectorySnapshot.count || 0) < Math.max(3, expectedVisibleCount)) { + throw new Error( + `reader_storybook_trajectory_too_short: world=${worldSummary.world_id} count=${trajectorySnapshot.count} expected=${Math.max(3, expectedVisibleCount)}` + ); + } + if (Number(trajectorySnapshot.active_count || 0) !== 1) { + throw new Error(`reader_storybook_active_trajectory_count_invalid: world=${worldSummary.world_id} count=${trajectorySnapshot.active_count}`); + } + completeStep(`verify_trajectory_window:${worldSummary.world_id}`); + + markStep(`sample_storybook_chapters:${worldSummary.world_id}`); + const samplePlan = await evaluate(`(() => { + const cards = Array.from(document.querySelectorAll('#reader-v2-storybook-sequence .reader-shell-v2__trajectory-card')); + const indexes = Array.from(new Set([0, Math.max(0, Math.floor((cards.length - 1) / 2)), Math.max(0, cards.length - 1)])); + return indexes.map((visibleIndex) => ({ + visible_index: visibleIndex, + title: cards[visibleIndex]?.querySelector('strong')?.innerText || '', + action: cards[visibleIndex]?.dataset.readerV2Action || '', + })); + })()`); + + const sampledChapters = []; + for (const sample of samplePlan) { + const clicked = await clickTrajectoryCard(evaluate, sample.visible_index); + await waitFor( + evaluate, + `storybook sample ${worldSummary.world_id}:${sample.visible_index}`, + `(() => { + const cards = Array.from(document.querySelectorAll('#reader-v2-storybook-sequence .reader-shell-v2__trajectory-card')); + const card = cards[${Number(sample.visible_index)}]; + return Boolean(card && card.classList.contains('is-active') && (document.querySelector('#reader-v2-storybook-title')?.innerText || '').trim().length > 0); + })()`, + 30000 + ); + const chapterSnapshot = await evaluate(`(() => { + const nonEmptyBeats = Array.from(document.querySelectorAll('#reader-v2-storybook-beats .reader-shell-v2__beat-item:not(.reader-shell-v2__beat-item--empty)')); + const activeCard = document.querySelector('#reader-v2-storybook-sequence .reader-shell-v2__trajectory-card.is-active'); + const quote = (document.querySelector('#reader-v2-storybook-quote')?.innerText || '').trim(); + const beatTexts = nonEmptyBeats.map((item) => item.querySelector('.reader-shell-v2__beat-copy')?.innerText.trim() || '').filter(Boolean); + return { + chapter_title: (document.querySelector('#reader-v2-storybook-title')?.innerText || '').trim(), + quote, + quote_length: quote.length, + quote_placeholder: quote.includes('这里会显示') || quote.includes('章节引句'), + beat_count: beatTexts.length, + prose_length: (document.querySelector('#reader-v2-storybook-prose')?.innerText || '').trim().length, + active_trajectory_title: activeCard?.querySelector('strong')?.innerText || '', + selected_replay_index: Number(readerState.selectedReplayIndex ?? -1), + }; + })()`); + if (chapterSnapshot.quote_length < 8 || chapterSnapshot.quote_placeholder) { + throw new Error( + `reader_storybook_quote_unstable: world=${worldSummary.world_id} visible_index=${sample.visible_index} quote_length=${chapterSnapshot.quote_length}` + ); + } + if (chapterSnapshot.beat_count < 1) { + throw new Error(`reader_storybook_beats_missing: world=${worldSummary.world_id} visible_index=${sample.visible_index}`); + } + if (chapterSnapshot.prose_length < 400) { + throw new Error(`reader_storybook_prose_too_short: world=${worldSummary.world_id} visible_index=${sample.visible_index}`); + } + sampledChapters.push({ + visible_index: sample.visible_index, + requested_title: sample.title, + clicked_action: clicked.action, + chapter_title: chapterSnapshot.chapter_title, + quote_text: chapterSnapshot.quote, + quote_length: chapterSnapshot.quote_length, + beat_count: chapterSnapshot.beat_count, + prose_length: chapterSnapshot.prose_length, + active_trajectory_title: chapterSnapshot.active_trajectory_title, + selected_replay_index: chapterSnapshot.selected_replay_index, + }); + } + completeStep(`sample_storybook_chapters:${worldSummary.world_id}`); + + markStep(`return_to_landing:${worldSummary.world_id}`); + await clickButtonByText(evaluate, "#reader-v2-read-hero button", "返回书架"); + await waitFor( + evaluate, + `reader returns to landing ${worldSummary.world_id}`, + `document.querySelector('#app-shell')?.dataset.product === 'reader' + && new URL(location.href).searchParams.get('workspace') === 'landing'`, + 30000 + ); + completeStep(`return_to_landing:${worldSummary.world_id}`); + + return { + world_id: worldSummary.world_id, + session_id: worldSummary.session_id, + visible_trajectory_count: trajectorySnapshot.count, + sampled_chapter_count: sampledChapters.length, + sampled_quotes: sampledChapters.map((item) => item.quote_text), + sampled_quote_lengths: sampledChapters.map((item) => item.quote_length), + sampled_beat_counts: sampledChapters.map((item) => item.beat_count), + sampled_titles: sampledChapters.map((item) => item.chapter_title), + latest_title: sampledChapters[sampledChapters.length - 1]?.chapter_title || "", + }; + } + + try { + markStep("load_page_title"); + await waitFor(evaluate, "page title", `document.title === 'NarrativeOS Studio'`); + completeStep("load_page_title"); + + markStep("wait_for_bootstrap"); + await waitFor( + evaluate, + "reader app bootstrap", + `typeof ReaderRuntime === 'object' + && typeof ShellRuntime === 'object' + && document.querySelector('#reader-shell-v2') + && document.querySelector('#app-shell')?.dataset.product === 'reader'`, + 30000 + ); + completeStep("wait_for_bootstrap"); + + markStep("login_reader_identity"); + await waitFor( + evaluate, + "shell auth stage", + `document.querySelector('#shell-auth-actor-id') + && document.querySelector('#shell-auth-login') + && document.querySelector('#app-shell')?.dataset.authenticated === 'off'`, + 30000 + ); + await setValue(evaluate, "#shell-auth-actor-id", seed.auth_actor_id || seed.account_id); + await setValue(evaluate, "#shell-auth-password", seed.auth_password); + await clickButtonByText(evaluate, "#shell-auth-stage button", "登录"); + await waitFor( + evaluate, + "reader authenticated shell", + `document.querySelector('#app-shell')?.dataset.authenticated === 'on' + && document.querySelector('#reader-shell-v2') + && document.querySelector('#shell-auth-stage')?.offsetParent === null`, + 30000 + ); + completeStep("login_reader_identity"); + + markStep("verify_seeded_session_on_landing"); + await waitFor( + evaluate, + "reader seeded session visible", + `document.querySelector('#app-shell')?.dataset.product === 'reader' + && new URL(location.href).searchParams.get('workspace') === 'landing' + && document.querySelector('#reader-shell-v2')`, + 30000 + ); + completeStep("verify_seeded_session_on_landing"); + + const worldSummaries = []; + for (const worldSummary of seed.world_summaries || []) { + worldSummaries.push(await captureWorldStorybook(worldSummary)); + } + const distinctnessComparisons = crossPackDistinctnessComparisons(worldSummaries); + const titleWarnings = titleHomogenizationWarnings(distinctnessComparisons); + const generatedAt = new Date().toISOString(); + const historyPayload = appendHistoryEntry(readJsonFile(historyFile), { + generated_at: generatedAt, + world_ids: seed.world_ids || [], + cross_pack_distinctness: distinctnessComparisons, + title_homogenization_warnings: titleWarnings, + }); + writeJsonFile(historyFile, historyPayload); + const titleHomogenizationTrend = buildTitleHomogenizationTrend(historyPayload); + const titleHomogenizationHistorySummary = summarizeTitleHomogenizationHistory( + historyPayload, + titleHomogenizationTrend + ); + const promotedPairs = titleHomogenizationTrend.promoted_pairs || []; + const failedDistinctness = distinctnessComparisons.filter((item) => !item.passes_min_difference); + if (failedDistinctness.length) { + throw new Error( + `reader_storybook_cross_pack_distinctness_failed: ${JSON.stringify(failedDistinctness)}` + ); + } + + markStep("capture_storybook_screenshot"); + const screenshotPath = await captureScreenshot(storybookScreenshotFile); + completeStep("capture_storybook_screenshot"); + + if (consoleErrors.length) { + throw new Error(`Reader storybook long-route smoke emitted ${consoleErrors.length} console error(s)`); + } + + const summaryPayload = { + suite_scope: "reader_storybook_long_route_smoke", + reader_seed_world_ids: seed.world_ids, + reader_seed_target_chapters: seed.target_chapters, + reader_seed_min_target_chapters: seed.min_target_chapters, + reader_seed_reached_chapters: (seed.world_summaries || []).map((item) => item.reached_chapters), + reader_seed_quality_guard_retry_count: (seed.world_summaries || []).map((item) => item.quality_guard_retry_count), + reader_seed_quote_coverage_rate: (seed.world_summaries || []).map((item) => item.quote_coverage_rate), + reader_seed_beats_coverage_rate: (seed.world_summaries || []).map((item) => item.beats_coverage_rate), + reader_storybook_world_summaries: worldSummaries, + reader_storybook_cross_pack_distinctness: distinctnessComparisons, + reader_storybook_title_homogenization_warnings: titleWarnings, + reader_storybook_title_homogenization_warning_count: titleWarnings.length, + reader_storybook_title_homogenization_history_summary: titleHomogenizationHistorySummary, + reader_storybook_title_homogenization_trend: titleHomogenizationTrend, + reader_storybook_title_homogenization_promoted_pairs: promotedPairs, + reader_storybook_visible_trajectory_count: worldSummaries.map((item) => item.visible_trajectory_count), + reader_storybook_sampled_chapter_count: worldSummaries.map((item) => item.sampled_chapter_count), + reader_storybook_sampled_quote_lengths: worldSummaries.map((item) => item.sampled_quote_lengths), + reader_storybook_sampled_beat_counts: worldSummaries.map((item) => item.sampled_beat_counts), + reader_storybook_sampled_titles: worldSummaries.map((item) => item.sampled_titles), + reader_storybook_latest_title: worldSummaries.map((item) => item.latest_title), + reader_storybook_screenshot_file: screenshotPath, + }; + const resultPayload = { + status: "ok", + schema_version: "frontend_qa_result/v1", + guard: { + id: "reader_storybook_long_route_smoke", + label: "Reader Storybook Long-Route Smoke", + }, + summary_meta: { + primary_key: "completed_steps", + primary_count: stepOrder.length, + summary_key_count: Object.keys(summaryPayload).length, + }, + artifacts: { + result_file: resultFile, + failure_artifact_file: null, + failure_screenshot_file: null, + storybook_screenshot_file: screenshotPath, + seed_file: seedFile, + history_file: historyFile, + }, + app_url: url, + completed_steps: stepOrder, + failed_step: null, + console_errors: consoleErrors, + summary: summaryPayload, + failure_artifact_file: null, + failure_screenshot_file: null, + }; + writeResult(resultPayload); + console.log(JSON.stringify(resultPayload, null, 2)); + } catch (error) { + const failureSnapshot = await captureFailureSnapshot(); + const failureScreenshotPath = await captureScreenshot(failureScreenshotFile).catch((captureError) => { + return captureError && captureError.message ? captureError.message : String(captureError); + }); + const failureArtifact = { + status: "error", + app_url: url, + seed, + completed_steps: stepOrder, + failed_step: currentStep, + error_message: error && error.message ? error.message : String(error), + snapshot: failureSnapshot, + failure_screenshot_file: + typeof failureScreenshotPath === "string" && failureScreenshotPath.endsWith(".png") + ? failureScreenshotPath + : null, + failure_screenshot_error: + typeof failureScreenshotPath === "string" && !failureScreenshotPath.endsWith(".png") + ? failureScreenshotPath + : null, + }; + writeFailureArtifact(failureArtifact); + const resultPayload = { + status: "error", + schema_version: "frontend_qa_result/v1", + guard: { + id: "reader_storybook_long_route_smoke", + label: "Reader Storybook Long-Route Smoke", + }, + app_url: url, + completed_steps: stepOrder, + failed_step: currentStep, + console_errors: consoleErrors, + error_message: error && error.message ? error.message : String(error), + failure_artifact_file: failureArtifactFile, + failure_screenshot_file: + typeof failureScreenshotPath === "string" && failureScreenshotPath.endsWith(".png") + ? failureScreenshotPath + : null, + }; + writeResult(resultPayload); + throw error; + } finally { + ws.close(); + } +} + +main().catch((error) => { + console.error("READER_STORYBOOK_LONG_ROUTE_SMOKE_ERROR"); + console.error(error && error.stack ? error.stack : error); + process.exit(1); +}); diff --git a/scripts/write_reader_storybook_long_route_smoke_step_summary.py b/scripts/write_reader_storybook_long_route_smoke_step_summary.py new file mode 100644 index 0000000..bc67d9a --- /dev/null +++ b/scripts/write_reader_storybook_long_route_smoke_step_summary.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def read_json(path: Path) -> dict: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def read_text(path_str: str | None) -> str: + if not path_str: + return "" + path = Path(path_str) + if not path.exists(): + return "" + return path.read_text(encoding="utf-8")[-4000:] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Render GitHub step summary for the Reader storybook long-route smoke.") + parser.add_argument("--result-file", required=True) + parser.add_argument("--server-log", required=True) + parser.add_argument("--chrome-log", required=True) + parser.add_argument("--failure-artifact", required=True) + args = parser.parse_args() + + result = read_json(Path(args.result_file)) + failure = read_json(Path(args.failure_artifact)) + status = result.get("status", "unknown") + summary = result.get("summary") or {} + artifacts = result.get("artifacts") or {} + + print("# Reader Storybook Long-Route Smoke") + print("") + print(f"- Status: `{status}`") + print(f"- Failed step: `{result.get('failed_step')}`") + print(f"- Completed steps: `{', '.join(result.get('completed_steps', [])) or '-'}`") + if artifacts: + print(f"- Result artifact: `{artifacts.get('result_file', '-')}`") + print(f"- History artifact: `{artifacts.get('history_file', '-')}`") + if summary: + print("") + print("## Summary") + print("") + for key in [ + "reader_seed_world_ids", + "reader_seed_target_chapters", + "reader_seed_reached_chapters", + "reader_storybook_visible_trajectory_count", + "reader_storybook_title_homogenization_warning_count", + "reader_storybook_title_homogenization_history_summary", + "reader_storybook_title_homogenization_promoted_pairs", + ]: + if key in summary: + print(f"- {key}: `{summary[key]}`") + + console_errors = result.get("console_errors") or [] + if console_errors: + print("") + print("## Console Errors") + print("") + for item in console_errors[:10]: + print(f"- `{item.get('type', 'error')}` {item.get('text', '')}") + + if failure: + print("") + print("## Failure Snapshot") + print("") + print(f"- Error: `{failure.get('error_message', '-')}`") + print(f"- Screenshot: `{failure.get('failure_screenshot_file') or failure.get('screenshot', {}).get('screenshot_file') or '-'}`") + + server_log = read_text(args.server_log) + if server_log: + print("") + print("## Server Log Tail") + print("") + print("```text") + print(server_log) + print("```") + + chrome_log = read_text(args.chrome_log) + if chrome_log: + print("") + print("## Chrome Log Tail") + print("") + print("```text") + print(chrome_log) + print("```") + + +if __name__ == "__main__": + main() diff --git a/specs/arc_plan.schema.json b/specs/arc_plan.schema.json new file mode 100644 index 0000000..a9687a1 --- /dev/null +++ b/specs/arc_plan.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ArcPlan", + "type": "object", + "required": [ + "arc_id", + "volume_id", + "order", + "title", + "goal", + "conflict", + "reveal_budget", + "payoff_targets", + "completion_conditions", + "target_chapters", + "chapter_tasks" + ], + "properties": { + "arc_id": {"type": "string"}, + "volume_id": {"type": "string"}, + "order": {"type": "integer", "minimum": 1}, + "title": {"type": "string"}, + "goal": {"type": "string"}, + "conflict": {"type": "string"}, + "reveal_budget": {"type": "integer", "minimum": 0}, + "payoff_targets": { + "type": "array", + "items": {"type": "string"} + }, + "completion_conditions": { + "type": "array", + "items": {"type": "string"} + }, + "target_chapters": {"type": "integer", "minimum": 1}, + "arc_promises": { + "type": "array", + "items": {"type": "object", "additionalProperties": true} + }, + "chapter_tasks": { + "type": "array", + "items": {"$ref": "chapter_task.schema.json"} + } + } +} diff --git a/specs/chapter_budget_policy.schema.json b/specs/chapter_budget_policy.schema.json new file mode 100644 index 0000000..643e9b3 --- /dev/null +++ b/specs/chapter_budget_policy.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ChapterBudgetPolicy", + "type": "object", + "required": [ + "default_target_words", + "min_target_words", + "max_target_words", + "default_reveal_budget", + "duty_cycle" + ], + "properties": { + "default_target_words": {"type": "integer", "minimum": 200}, + "min_target_words": {"type": "integer", "minimum": 200}, + "max_target_words": {"type": "integer", "minimum": 200}, + "default_reveal_budget": {"type": "integer", "minimum": 0}, + "duty_cycle": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "advance_plot", + "advance_relationship", + "resolve_promise", + "expand_world", + "pace_breath", + "deliver_climax" + ] + } + } + } +} diff --git a/specs/chapter_task.schema.json b/specs/chapter_task.schema.json new file mode 100644 index 0000000..dc0f276 --- /dev/null +++ b/specs/chapter_task.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "LongformChapterTask", + "type": "object", + "required": [ + "chapter_task_id", + "objective", + "duty_type", + "target_words", + "reveal_budget", + "promise_actions" + ], + "properties": { + "chapter_task_id": {"type": "string"}, + "objective": {"type": "string"}, + "duty_type": { + "type": "string", + "enum": [ + "advance_plot", + "advance_relationship", + "resolve_promise", + "expand_world", + "pace_breath", + "deliver_climax" + ] + }, + "target_words": {"type": "integer", "minimum": 200}, + "reveal_budget": {"type": "integer", "minimum": 0}, + "promise_actions": { + "type": "array", + "items": {"type": "string"} + }, + "promise_targets": { + "type": "array", + "items": {"type": "string"} + }, + "allow_terminal": {"type": "boolean"}, + "bridge_only": {"type": "boolean"}, + "notes": {"type": "string"}, + "quality_contract": { + "type": "object", + "required": [ + "delayed_payoff_window", + "continuation_pressure_required", + "max_exposition_ratio", + "min_dialogue_action_ratio", + "min_detail_density" + ], + "properties": { + "delayed_payoff_window": { + "type": "object", + "required": ["min_chapters", "max_chapters"], + "properties": { + "min_chapters": {"type": "integer", "minimum": 0}, + "max_chapters": {"type": "integer", "minimum": 0} + } + }, + "continuation_pressure_required": {"type": "boolean"}, + "max_exposition_ratio": {"type": "number"}, + "min_dialogue_action_ratio": {"type": "number"}, + "min_detail_density": {"type": "number"} + } + } + } +} diff --git a/specs/longform_memory_unit.schema.json b/specs/longform_memory_unit.schema.json new file mode 100644 index 0000000..cd86c7b --- /dev/null +++ b/specs/longform_memory_unit.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "LongformMemoryUnit", + "type": "object", + "required": [ + "memory_id", + "memory_type", + "scope", + "entity_refs", + "summary", + "importance", + "created_chapter", + "last_referenced_chapter", + "resolution_status" + ], + "properties": { + "memory_id": {"type": "string"}, + "memory_type": {"type": "string"}, + "scope": {"type": "string"}, + "entity_refs": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": {"type": "string"} + } + }, + "summary": {"type": "string"}, + "importance": {"type": "number"}, + "created_chapter": {"type": "integer", "minimum": 0}, + "last_referenced_chapter": {"type": "integer", "minimum": 0}, + "resolution_status": {"type": "string"} + } +} diff --git a/specs/narrative_state.schema.json b/specs/narrative_state.schema.json index 5bf1278..dbdc30c 100644 --- a/specs/narrative_state.schema.json +++ b/specs/narrative_state.schema.json @@ -574,6 +574,98 @@ "type": "string" } }, + "current_series_id": { + "type": ["string", "null"] + }, + "current_volume_id": { + "type": ["string", "null"] + }, + "current_arc_id": { + "type": ["string", "null"] + }, + "current_chapter_task": { + "$ref": "chapter_task.schema.json" + }, + "word_budget": { + "type": "integer", + "minimum": 200 + }, + "canonical_memory": { + "type": "array", + "items": { + "$ref": "longform_memory_unit.schema.json" + } + }, + "active_arc_memory": { + "type": "array", + "items": { + "$ref": "longform_memory_unit.schema.json" + } + }, + "rolling_recap": { + "type": "array", + "items": { + "$ref": "longform_memory_unit.schema.json" + } + }, + "archive_memory": { + "type": "array", + "items": { + "$ref": "longform_memory_unit.schema.json" + } + }, + "volume_memory_snapshots": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "series_memory_snapshots": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "steering_ledger": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "storyline_checkpoint": { + "type": "object", + "additionalProperties": true + }, + "volume_storyline_checkpoint": { + "type": "object", + "additionalProperties": true + }, + "series_ending_checkpoint": { + "type": "object", + "additionalProperties": true + }, + "character_memory_runtime": { + "type": "object", + "additionalProperties": true + }, + "replan_checkpoint": { + "type": "object", + "additionalProperties": true + }, + "replan_history": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "replan_stability_metrics": { + "type": "object", + "additionalProperties": true + }, "rating_ceiling": { "type": "string", "enum": [ diff --git a/specs/quality_eval_run.schema.json b/specs/quality_eval_run.schema.json new file mode 100644 index 0000000..c994b04 --- /dev/null +++ b/specs/quality_eval_run.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "QualityEvalRun", + "type": "object", + "required": [ + "run_id", + "generated_at", + "sample_count", + "overall_pass_rate", + "veto_rate", + "average_content_score", + "grounding_pass_rate", + "failed_sample_count", + "failed_samples" + ], + "properties": { + "run_id": {"type": "string"}, + "generated_at": {"type": "string"}, + "sample_count": {"type": "integer"}, + "overall_pass_rate": {"type": "number"}, + "veto_rate": {"type": "number"}, + "average_content_score": {"type": "number"}, + "grounding_pass_rate": {"type": "number"}, + "failed_sample_count": {"type": "integer"}, + "failed_samples": { + "type": "array", + "items": {"type": "object"} + } + } +} diff --git a/specs/quality_eval_sample.schema.json b/specs/quality_eval_sample.schema.json new file mode 100644 index 0000000..7c11acd --- /dev/null +++ b/specs/quality_eval_sample.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "QualityEvalSample", + "type": "object", + "required": [ + "sample_id", + "scenario", + "risk_tier", + "input", + "context", + "materials", + "expected_behavior", + "expected_veto", + "expected_reason_codes", + "rubric_targets", + "grounding_expectation" + ], + "properties": { + "sample_id": {"type": "string"}, + "scenario": {"type": "string"}, + "risk_tier": {"type": "string"}, + "input": {"type": "object"}, + "context": {"type": "object"}, + "materials": {"type": "object"}, + "expected_behavior": {"type": "string"}, + "expected_veto": {"type": "boolean"}, + "expected_reason_codes": { + "type": "array", + "items": {"type": "string"} + }, + "rubric_targets": {"type": "object"}, + "grounding_expectation": { + "type": "object", + "required": ["status"], + "properties": { + "status": {"type": "string"}, + "max_unsupported_claims": {"type": ["integer", "null"]} + } + } + } +} diff --git a/specs/scene_plan.schema.json b/specs/scene_plan.schema.json index bd0372a..3a86ab9 100644 --- a/specs/scene_plan.schema.json +++ b/specs/scene_plan.schema.json @@ -54,6 +54,30 @@ }, "ending_gate": { "type": "object" + }, + "quality_contract": { + "type": "object", + "required": [ + "variation_axes", + "detail_anchor_types", + "dialogue_pressure", + "continuation_obligation" + ], + "properties": { + "variation_axes": { + "type": "array", + "items": {"type": "string"} + }, + "detail_anchor_types": { + "type": "array", + "items": {"type": "string"} + }, + "dialogue_pressure": { + "type": "string", + "enum": ["low", "medium", "high"] + }, + "continuation_obligation": {"type": "boolean"} + } } } -} \ No newline at end of file +} diff --git a/specs/series_plan.schema.json b/specs/series_plan.schema.json new file mode 100644 index 0000000..0ba7010 --- /dev/null +++ b/specs/series_plan.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SeriesPlan", + "type": "object", + "required": [ + "series_id", + "title", + "total_volume_target", + "total_chapter_target", + "target_word_count" + ], + "properties": { + "series_id": {"type": "string"}, + "title": {"type": "string"}, + "total_volume_target": {"type": "integer", "minimum": 1}, + "total_chapter_target": {"type": "integer", "minimum": 1}, + "target_word_count": {"type": "integer", "minimum": 1000}, + "theme_statement": {"type": "string"}, + "series_promises": { + "type": "array", + "items": {"type": "object", "additionalProperties": true} + } + } +} diff --git a/specs/volume_plan.schema.json b/specs/volume_plan.schema.json new file mode 100644 index 0000000..633cbc6 --- /dev/null +++ b/specs/volume_plan.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "VolumePlan", + "type": "object", + "required": [ + "volume_id", + "order", + "title", + "goal", + "target_chapters", + "climax_definition", + "end_state" + ], + "properties": { + "volume_id": {"type": "string"}, + "order": {"type": "integer", "minimum": 1}, + "title": {"type": "string"}, + "goal": {"type": "string"}, + "target_chapters": {"type": "integer", "minimum": 1}, + "climax_definition": {"type": "string"}, + "end_state": {"type": "string"}, + "volume_promises": { + "type": "array", + "items": {"type": "object", "additionalProperties": true} + } + } +} diff --git a/specs/worldpack.schema.json b/specs/worldpack.schema.json index 6e812e6..68a8049 100644 --- a/specs/worldpack.schema.json +++ b/specs/worldpack.schema.json @@ -57,6 +57,46 @@ } } }, + "series_plan": { + "$ref": "series_plan.schema.json" + }, + "volume_plans": { + "type": "array", + "items": { + "$ref": "volume_plan.schema.json" + } + }, + "arc_plans": { + "type": "array", + "items": { + "$ref": "arc_plan.schema.json" + } + }, + "chapter_budget_policy": { + "$ref": "chapter_budget_policy.schema.json" + }, + "memory_compression_policy": { + "type": "object", + "additionalProperties": true + }, + "series_storyline_contract": { + "type": "object", + "additionalProperties": true + }, + "character_memory_profiles": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "steering_guardrails": { + "type": "object", + "additionalProperties": true + }, + "metadata": { + "type": "object" + }, "world_bible": { "type": "object" }, diff --git a/src/narrativeos/benchmark/content_quality_contract_gate.py b/src/narrativeos/benchmark/content_quality_contract_gate.py new file mode 100644 index 0000000..f9e36bb --- /dev/null +++ b/src/narrativeos/benchmark/content_quality_contract_gate.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any, Dict, List + + +def evaluate_content_quality_contract_gate(report: Dict[str, Any]) -> Dict[str, Any]: + worlds = [dict(item or {}) for item in list(report.get("worlds") or [])] + checks: List[Dict[str, Any]] = [] + failed_world_items: List[Dict[str, Any]] = [] + blocking_worlds: List[str] = [] + config_version = "" + for world in worlds: + world_id = str(world.get("world_id") or "") + coverage = dict(world.get("content_quality_contract_coverage") or {}) + window_metrics = dict(world.get("content_quality_contract_window_metrics") or {}) + gate_enforced = bool(window_metrics.get("gate_enforced", coverage.get("gate_enforced", False))) + if not bool(coverage.get("applicable")): + continue + if not gate_enforced: + continue + config_version = str(coverage.get("config_version") or config_version) + if not bool(coverage.get("ok", False)): + checks.append( + { + "key": "asset_contract_coverage", + "world_id": world_id, + "ok": False, + "reason": "content_quality_contract_asset_coverage_missing", + "actual": list(coverage.get("failed_checks") or []), + "threshold": "full_coverage", + } + ) + blocking_worlds.append(world_id) + failed_world_items.append( + { + "world_id": world_id, + "reason": "content_quality_contract_asset_coverage_missing", + "failed_checks": list(coverage.get("failed_checks") or []), + } + ) + if bool(window_metrics.get("enabled")): + thresholds = dict(window_metrics.get("thresholds") or {}) + for key, actual, threshold_key, reason in [ + ("early_window_q03_q04_share", float(window_metrics.get("early_window_q03_q04_share", 0.0) or 0.0), "early_window_q03_q04_share_max", "content_quality_contract_early_window_exceeded"), + ("mid_window_repeat_breach_rate", float(window_metrics.get("mid_window_repeat_breach_rate", 0.0) or 0.0), "mid_window_repeat_breach_rate_max", "content_quality_contract_mid_repeat_exceeded"), + ("mid_window_exposition_breach_rate", float(window_metrics.get("mid_window_exposition_breach_rate", 0.0) or 0.0), "mid_window_exposition_breach_rate_max", "content_quality_contract_mid_exposition_exceeded"), + ("late_window_q09_breach_rate", float(window_metrics.get("late_window_q09_breach_rate", 0.0) or 0.0), "late_window_q09_breach_rate_max", "content_quality_contract_late_q09_exceeded"), + ]: + threshold = float(thresholds.get(threshold_key, 0.0) or 0.0) + ok = actual <= threshold + checks.append( + { + "key": key, + "world_id": world_id, + "ok": ok, + "reason": f"{reason}_met" if ok else reason, + "actual": round(actual, 3), + "threshold": round(threshold, 3), + } + ) + if not ok and world_id not in blocking_worlds: + blocking_worlds.append(world_id) + failed_world_items.append( + { + "world_id": world_id, + "reason": reason, + "failed_checks": [key], + } + ) + failed_checks = [str(item.get("reason") or "") for item in checks if not item.get("ok")] + return { + "config_version": config_version, + "ok": not failed_checks, + "checks": checks, + "failed_checks": failed_checks, + "blocking_worlds": blocking_worlds, + "failed_world_items": failed_world_items, + } diff --git a/src/narrativeos/benchmark/merge_gate.py b/src/narrativeos/benchmark/merge_gate.py index 4362f8c..e2ce105 100644 --- a/src/narrativeos/benchmark/merge_gate.py +++ b/src/narrativeos/benchmark/merge_gate.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import Any, Dict, Iterable, List, Sequence +from .release_quality_gate import evaluate_release_quality_gate + REQUIRED_PR_FIELDS = ( "Lane", @@ -72,6 +74,22 @@ def validate_benchmark_report(report: Dict[str, Any]) -> List[str]: regressions = list(delta_summary.get("regressions", [])) if regressions: errors.append("metric_regression_detected") + quality_gate = dict(report.get("phase_a_quality_gate") or evaluate_release_quality_gate(report)) + commercial_long_route_gate = dict(report.get("commercial_long_route_gate") or {}) + errors.extend(str(item) for item in quality_gate.get("failed_checks", [])) + benchmark_mode = str(report.get("benchmark_mode", "standard") or "standard") + if benchmark_mode == "longform_100": + signoff = dict(report.get("longform_l1_signoff", {})) + if not signoff: + errors.append("missing_longform_l1_signoff") + elif signoff.get("status") != "ready": + errors.append("longform_l1_signoff_blocked") + if benchmark_mode == "longform_100_interactive": + signoff = dict(report.get("interactive_longform_signoff", {})) + if not signoff: + errors.append("missing_interactive_longform_signoff") + elif signoff.get("status") != "ready": + errors.append("interactive_longform_signoff_blocked") return errors @@ -94,14 +112,27 @@ def validate_pr_evidence(pr_body: str) -> List[str]: def build_gate_summary(report: Dict[str, Any], *, benchmark_errors: Sequence[str], pr_errors: Sequence[str]) -> str: delta_summary = dict(report.get("delta_summary", {})) + signoff = dict(report.get("longform_l1_signoff", {})) + interactive_signoff = dict(report.get("interactive_longform_signoff", {})) + quality_gate = dict(report.get("phase_a_quality_gate") or evaluate_release_quality_gate(report)) + commercial_long_route_gate = dict(report.get("commercial_long_route_gate") or {}) strongest = ", ".join(item.get("world_id", "-") for item in report.get("strongest_packs", [])) or "-" weakest = ", ".join(item.get("world_id", "-") for item in report.get("weakest_packs", [])) or "-" lines = [ "## Cross-Pack Merge Gate", + f"- benchmark_mode: {report.get('benchmark_mode', 'standard')}", f"- cross_pack_pass_rate: {float(report.get('cross_pack_pass_rate', 0.0)):.3f}", f"- cross_pack_pass_rate_delta: {float(delta_summary.get('cross_pack_pass_rate_delta', 0.0)):+.3f}", f"- strongest packs: {strongest}", f"- weakest packs: {weakest}", + f"- phase_a_quality_gate: {'pass' if quality_gate.get('ok') else 'blocked'}", + f"- phase_a_quality_gate_config: {quality_gate.get('config_version', '-')}", + f"- phase_a_quality_gate_failures: {', '.join(quality_gate.get('failed_checks', [])) if quality_gate.get('failed_checks') else 'none'}", + f"- commercial_long_route_gate: {'pass' if commercial_long_route_gate.get('ok', True) else 'blocked'}", + f"- commercial_long_route_gate_applicable: {'yes' if commercial_long_route_gate.get('applicable') else 'no'}", + f"- longform_l1_signoff: {signoff.get('status', '-')}", + f"- interactive_longform_signoff: {interactive_signoff.get('status', '-')}", + f"- signoff_blocking_worlds: {', '.join(signoff.get('blocking_worlds', [])) if signoff.get('blocking_worlds') else '-'}", f"- benchmark errors: {', '.join(benchmark_errors) if benchmark_errors else 'none'}", f"- PR evidence errors: {', '.join(pr_errors) if pr_errors else 'none'}", ] diff --git a/src/narrativeos/benchmark/release_quality_gate.py b/src/narrativeos/benchmark/release_quality_gate.py new file mode 100644 index 0000000..5f91daa --- /dev/null +++ b/src/narrativeos/benchmark/release_quality_gate.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional + + +DEFAULT_RELEASE_QUALITY_GATE_PATH = ( + Path(__file__).resolve().parents[3] / "configs" / "release_quality_gate.json" +) +DEFAULT_RELEASE_QUALITY_GATE = { + "config_version": "phase_a_quality_gate_v1", + "cross_pack_pass_rate_min": 0.9, + "weakest_pack_limit": 3, + "weakest_pack_pass_rate_min": 0.55, + "commercial_long_route_chapter_budget_min": 50, + "commercial_long_route_weakest_long_route_quality_min": 0.5, + "commercial_long_route_weakest_completion_ratio_min": 0.8, + "commercial_long_route_weakest_mid_arc_drop_max": 0.35, + "weakest_pack_issue_share_max": { + "Q03": 0.35, + "Q04": 0.3, + "Q05": 0.3, + "Q09": 0.2, + }, +} + + +def load_release_quality_gate_config(path: Optional[Path] = None) -> Dict[str, Any]: + config_path = path or DEFAULT_RELEASE_QUALITY_GATE_PATH + if config_path.exists(): + payload = json.loads(config_path.read_text(encoding="utf-8")) + return { + "config_version": str(payload.get("config_version") or DEFAULT_RELEASE_QUALITY_GATE["config_version"]), + "cross_pack_pass_rate_min": float( + payload.get("cross_pack_pass_rate_min", DEFAULT_RELEASE_QUALITY_GATE["cross_pack_pass_rate_min"]) + ), + "weakest_pack_limit": int(payload.get("weakest_pack_limit", DEFAULT_RELEASE_QUALITY_GATE["weakest_pack_limit"])), + "weakest_pack_pass_rate_min": float( + payload.get("weakest_pack_pass_rate_min", DEFAULT_RELEASE_QUALITY_GATE["weakest_pack_pass_rate_min"]) + ), + "commercial_long_route_chapter_budget_min": int( + payload.get( + "commercial_long_route_chapter_budget_min", + DEFAULT_RELEASE_QUALITY_GATE["commercial_long_route_chapter_budget_min"], + ) + ), + "commercial_long_route_weakest_long_route_quality_min": float( + payload.get( + "commercial_long_route_weakest_long_route_quality_min", + DEFAULT_RELEASE_QUALITY_GATE["commercial_long_route_weakest_long_route_quality_min"], + ) + ), + "commercial_long_route_weakest_completion_ratio_min": float( + payload.get( + "commercial_long_route_weakest_completion_ratio_min", + DEFAULT_RELEASE_QUALITY_GATE["commercial_long_route_weakest_completion_ratio_min"], + ) + ), + "commercial_long_route_weakest_mid_arc_drop_max": float( + payload.get( + "commercial_long_route_weakest_mid_arc_drop_max", + DEFAULT_RELEASE_QUALITY_GATE["commercial_long_route_weakest_mid_arc_drop_max"], + ) + ), + "weakest_pack_issue_share_max": { + key: float(value) + for key, value in dict( + payload.get("weakest_pack_issue_share_max", DEFAULT_RELEASE_QUALITY_GATE["weakest_pack_issue_share_max"]) + ).items() + }, + } + return dict(DEFAULT_RELEASE_QUALITY_GATE) + + +def _issue_share(issue_mix: List[Dict[str, Any]], issue_code: str) -> float: + for item in issue_mix or []: + if str(item.get("issue_code") or "") == issue_code: + return float(item.get("share", 0.0) or 0.0) + return 0.0 + + +def _commercial_long_route_checks(report: Dict[str, Any], thresholds: Dict[str, Any]) -> List[Dict[str, Any]]: + benchmark_mode = str(report.get("benchmark_mode") or "standard") + chapter_budget = int(report.get("chapter_budget", 0) or 0) + required_budget = int(thresholds.get("commercial_long_route_chapter_budget_min", 50) or 50) + if benchmark_mode != "long_route" or chapter_budget < required_budget: + return [] + + weakest_limit = max(1, int(thresholds.get("weakest_pack_limit", 3) or 3)) + weakest_packs = list(report.get("weakest_packs") or report.get("top_failing_packs") or [])[:weakest_limit] + quality_min = float(thresholds.get("commercial_long_route_weakest_long_route_quality_min", 0.5) or 0.5) + completion_min = float(thresholds.get("commercial_long_route_weakest_completion_ratio_min", 0.8) or 0.8) + mid_arc_drop_max = float(thresholds.get("commercial_long_route_weakest_mid_arc_drop_max", 0.35) or 0.35) + + missing_evidence: List[Dict[str, Any]] = [] + readability_failures: List[Dict[str, Any]] = [] + focus_issue_failures: List[Dict[str, Any]] = [] + focus_issue_limits = dict(thresholds.get("weakest_pack_issue_share_max", {})) + for pack in weakest_packs: + world_id = str(pack.get("world_id") or "") + issue_mix = list(pack.get("issue_mix") or []) + missing_keys = [ + key + for key in ("long_route_quality", "mid_arc_drop", "completion_ratio", "stop_reason", "issue_mix") + if key not in pack + ] + if missing_keys: + missing_evidence.append({"world_id": world_id, "missing_keys": missing_keys}) + continue + + long_route_quality = float(pack.get("long_route_quality", 0.0) or 0.0) + completion_ratio = float(pack.get("completion_ratio", 0.0) or 0.0) + mid_arc_drop = float(pack.get("mid_arc_drop", 0.0) or 0.0) + failed_metrics: List[str] = [] + if long_route_quality < quality_min: + failed_metrics.append("long_route_quality") + if completion_ratio < completion_min: + failed_metrics.append("completion_ratio") + if mid_arc_drop > mid_arc_drop_max: + failed_metrics.append("mid_arc_drop") + if failed_metrics: + readability_failures.append( + { + "world_id": world_id, + "failed_metrics": failed_metrics, + "long_route_quality": round(long_route_quality, 3), + "completion_ratio": round(completion_ratio, 3), + "mid_arc_drop": round(mid_arc_drop, 3), + "stop_reason": pack.get("stop_reason"), + } + ) + + exceeded_focus_issues = [] + for issue_code in ("Q03", "Q04", "Q05", "Q09"): + share = _issue_share(issue_mix, issue_code) + limit = float(focus_issue_limits.get(issue_code, 1.0) or 1.0) + if share > limit: + exceeded_focus_issues.append( + { + "issue_code": issue_code, + "share": round(share, 3), + "threshold": round(limit, 3), + } + ) + if exceeded_focus_issues: + focus_issue_failures.append({"world_id": world_id, "issues": exceeded_focus_issues}) + + return [ + { + "key": "commercial_long_route_scope", + "ok": bool(report.get("benchmark_scope_complete", True)) and bool(weakest_packs), + "reason": "commercial_long_route_scope_met" + if bool(report.get("benchmark_scope_complete", True)) and bool(weakest_packs) + else "commercial_long_route_scope_incomplete", + "actual": { + "benchmark_mode": benchmark_mode, + "chapter_budget": chapter_budget, + "benchmark_scope_complete": bool(report.get("benchmark_scope_complete", True)), + "weakest_pack_count": len(weakest_packs), + }, + "threshold": {"benchmark_mode": "long_route", "chapter_budget_min": required_budget}, + }, + { + "key": "commercial_long_route_weakest_evidence", + "ok": not missing_evidence, + "reason": "commercial_long_route_weakest_evidence_present" + if not missing_evidence + else "commercial_long_route_weakest_evidence_missing", + "actual": missing_evidence, + "threshold": ["long_route_quality", "mid_arc_drop", "completion_ratio", "stop_reason", "issue_mix"], + "evaluated_world_ids": [str(pack.get("world_id") or "") for pack in weakest_packs], + }, + { + "key": "commercial_long_route_readability", + "ok": not readability_failures, + "reason": "commercial_long_route_readability_met" + if not readability_failures + else "commercial_long_route_readability_below_min", + "actual": readability_failures, + "threshold": { + "long_route_quality_min": round(quality_min, 3), + "completion_ratio_min": round(completion_min, 3), + "mid_arc_drop_max": round(mid_arc_drop_max, 3), + }, + "evaluated_world_ids": [str(pack.get("world_id") or "") for pack in weakest_packs], + }, + { + "key": "commercial_long_route_focus_issues", + "ok": not focus_issue_failures, + "reason": "commercial_long_route_focus_issues_met" + if not focus_issue_failures + else "commercial_long_route_focus_issue_share_exceeded", + "actual": focus_issue_failures, + "threshold": {key: round(float(value), 3) for key, value in focus_issue_limits.items()}, + "evaluated_world_ids": [str(pack.get("world_id") or "") for pack in weakest_packs], + }, + ] + + +def evaluate_commercial_long_route_gate( + report: Dict[str, Any], + *, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + thresholds = dict(config or load_release_quality_gate_config()) + checks = _commercial_long_route_checks(report, thresholds) + applicable = bool(checks) + failed_checks = [item["reason"] for item in checks if not item.get("ok")] + return { + "config_version": str(thresholds.get("config_version") or ""), + "applicable": applicable, + "ok": not failed_checks if applicable else True, + "checks": checks, + "failed_checks": failed_checks, + "thresholds": { + "benchmark_mode": "long_route", + "chapter_budget_min": int(thresholds.get("commercial_long_route_chapter_budget_min", 50) or 50), + "weakest_long_route_quality_min": float( + thresholds.get("commercial_long_route_weakest_long_route_quality_min", 0.5) or 0.5 + ), + "weakest_completion_ratio_min": float( + thresholds.get("commercial_long_route_weakest_completion_ratio_min", 0.8) or 0.8 + ), + "weakest_mid_arc_drop_max": float( + thresholds.get("commercial_long_route_weakest_mid_arc_drop_max", 0.35) or 0.35 + ), + }, + } + + +def evaluate_release_quality_gate( + report: Dict[str, Any], + *, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + thresholds = dict(config or load_release_quality_gate_config()) + weakest_limit = max(1, int(thresholds.get("weakest_pack_limit", 3) or 3)) + weakest_packs = list(report.get("weakest_packs") or report.get("top_failing_packs") or [])[:weakest_limit] + checks: List[Dict[str, Any]] = [] + + cross_pack_pass_rate = float(report.get("cross_pack_pass_rate", 0.0) or 0.0) + cross_pack_min = float(thresholds.get("cross_pack_pass_rate_min", 0.9) or 0.9) + checks.append( + { + "key": "cross_pack_pass_rate", + "ok": cross_pack_pass_rate >= cross_pack_min, + "reason": "phase_a_cross_pack_pass_rate_met" + if cross_pack_pass_rate >= cross_pack_min + else "phase_a_cross_pack_pass_rate_below_min", + "actual": round(cross_pack_pass_rate, 3), + "threshold": round(cross_pack_min, 3), + } + ) + + weakest_pack_pass_rate_min = float(thresholds.get("weakest_pack_pass_rate_min", 0.55) or 0.55) + weakest_pack_pass_rate_evaluated = [ + pack for pack in weakest_packs if "pass_rate" in pack and pack.get("pass_rate") is not None + ] + weakest_pack_failures = [ + { + "world_id": str(pack.get("world_id") or ""), + "pass_rate": round(float(pack.get("pass_rate", 0.0) or 0.0), 3), + } + for pack in weakest_pack_pass_rate_evaluated + if float(pack.get("pass_rate", 0.0) or 0.0) < weakest_pack_pass_rate_min + ] + checks.append( + { + "key": "weakest_pack_pass_rate", + "ok": not weakest_pack_failures, + "reason": "phase_a_weakest_pack_pass_rate_met" + if not weakest_pack_failures + else "phase_a_weakest_pack_pass_rate_below_min", + "actual": weakest_pack_failures, + "threshold": round(weakest_pack_pass_rate_min, 3), + "evaluated_world_ids": [str(pack.get("world_id") or "") for pack in weakest_pack_pass_rate_evaluated], + "skipped": not bool(weakest_pack_pass_rate_evaluated), + } + ) + + for issue_code, share_limit in dict(thresholds.get("weakest_pack_issue_share_max", {})).items(): + exceeded = [] + evaluated_world_ids = [] + for pack in weakest_packs: + world_id = str(pack.get("world_id") or "") + issue_mix = list(pack.get("issue_mix") or []) + if not world_id or not issue_mix: + continue + evaluated_world_ids.append(world_id) + share = _issue_share(issue_mix, issue_code) + if share > float(share_limit): + exceeded.append({"world_id": world_id, "share": round(share, 3)}) + checks.append( + { + "key": f"{issue_code.lower()}_weakest_issue_share", + "ok": not exceeded, + "reason": f"phase_a_{issue_code.lower()}_weakest_issue_share_met" + if not exceeded + else f"phase_a_{issue_code.lower()}_weakest_issue_share_exceeded", + "actual": exceeded, + "threshold": round(float(share_limit), 3), + "evaluated_world_ids": evaluated_world_ids, + "skipped": not bool(evaluated_world_ids), + } + ) + + checks.extend(_commercial_long_route_checks(report, thresholds)) + + failed_checks = [item["reason"] for item in checks if not item.get("ok")] + skipped_checks = [item["key"] for item in checks if item.get("skipped")] + return { + "config_version": str(thresholds.get("config_version") or ""), + "thresholds": thresholds, + "ok": not failed_checks, + "checks": checks, + "failed_checks": failed_checks, + "skipped_checks": skipped_checks, + "evaluated_weakest_world_ids": [str(pack.get("world_id") or "") for pack in weakest_packs], + } diff --git a/src/narrativeos/benchmark/reporting.py b/src/narrativeos/benchmark/reporting.py index 87cb3a6..75c4256 100644 --- a/src/narrativeos/benchmark/reporting.py +++ b/src/narrativeos/benchmark/reporting.py @@ -1,7 +1,12 @@ from __future__ import annotations +from datetime import datetime, timezone from typing import Any, Dict, Iterable, List, Sequence +from ..content_quality_strategy_bundles import ( + build_strategy_validation_summary, + infer_strategy_bundles_for_diagnostic, +) from ..eval.taxonomy import ISSUE_TAXONOMY @@ -155,6 +160,18 @@ }, } +POLISH_STOP_THRESHOLDS = { + "pass_rate_min": 0.99, + "block_rate_max": 0.0, + "long_route_quality_min": 0.85, + "scene_detail_density_min": 0.03, + "dialogue_distinctness_min": 0.55, + "dialogue_ratio_min": 0.55, + "diagnostic_score_max": 0.08, +} +LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS = 24 * 7 +INTERACTIVE_LONG_ROUTE_ISSUE_CODES = ("Q03", "Q04", "Q05", "Q09") + def _metric_delta(current: Dict[str, Any], baseline: Dict[str, Any], key: str) -> float: return round(float(current.get(key, 0.0)) - float(baseline.get(key, 0.0)), 3) @@ -633,6 +650,83 @@ def aggregate_by(field: str) -> List[Dict[str, Any]]: } +WINDOW_BREACH_PLAYBOOK = { + "early_window_q03_q04_share": { + "issue_codes": ["Q03", "Q04"], + "module": "writer", + "asset": "scene_blueprints", + "policy": "scene_realization_contracts", + "window_label": "early", + "summary": "早期窗口的 Q03/Q04 复合 breach 偏高。", + }, + "mid_window_repeat_breach_rate": { + "issue_codes": ["Q03"], + "module": "writer", + "asset": "scene_blueprints", + "policy": "dialogue_realism_policy", + "window_label": "mid", + "summary": "中段窗口重复 breach 偏高。", + }, + "mid_window_exposition_breach_rate": { + "issue_codes": ["Q04"], + "module": "writer", + "asset": "scene_blueprints", + "policy": "scene_realization_contracts", + "window_label": "mid", + "summary": "中段窗口解释比例 breach 偏高。", + }, + "mid_window_detail_breach_rate": { + "issue_codes": ["Q05"], + "module": "writer", + "asset": "sensory_grounding_policies", + "policy": "scene_realization_contracts", + "window_label": "mid", + "summary": "中段窗口 detail breach 偏高。", + }, + "late_window_q09_breach_rate": { + "issue_codes": ["Q09"], + "module": "planner", + "asset": "chapter_tasks", + "policy": "scene_realization_contracts", + "window_label": "late", + "summary": "后段窗口节奏/终局 breach 偏高。", + }, + "late_window_detail_breach_rate": { + "issue_codes": ["Q05"], + "module": "writer", + "asset": "sensory_grounding_policies", + "policy": "scene_realization_contracts", + "window_label": "late", + "summary": "后段窗口 detail breach 偏高。", + }, +} + + +def build_window_breach_attribution(window_metrics: Dict[str, Any]) -> List[Dict[str, Any]]: + thresholds = dict(window_metrics.get("thresholds") or {}) + attributions: List[Dict[str, Any]] = [] + for metric_name, config in WINDOW_BREACH_PLAYBOOK.items(): + actual = float(window_metrics.get(metric_name, 0.0) or 0.0) + threshold_key = f"{metric_name}_max" + threshold = float(thresholds.get(threshold_key, 0.0) or 0.0) + if actual <= threshold: + continue + attributions.append( + { + "metric": metric_name, + "window_label": config["window_label"], + "issue_codes": list(config["issue_codes"]), + "actual": round(actual, 3), + "threshold": round(threshold, 3), + "module": config["module"], + "asset": config["asset"], + "policy": config["policy"], + "summary": config["summary"], + } + ) + return attributions + + def build_weakest_pack_diagnostic( *, world_metrics: Dict[str, Any], @@ -646,7 +740,7 @@ def build_weakest_pack_diagnostic( weakest_dimensions=weakest_dimensions, pack_payload=pack_payload, ) - return { + diagnostic = { "world_id": world_metrics.get("world_id", ""), "diagnostic_rank": world_metrics.get("diagnostic_rank"), "diagnostic_score": world_metrics.get("diagnostic_score", 0.0), @@ -659,135 +753,1552 @@ def build_weakest_pack_diagnostic( "assets": attribution["assets"], "policies": attribution["policies"], }, + "window_breach_attribution": build_window_breach_attribution( + dict(world_metrics.get("content_quality_contract_window_metrics") or {}) + ), "asset_snapshot": attribution["asset_snapshot"], "next_fix_candidates": attribution["next_fix_candidates"], } + diagnostic["recommended_strategy_bundles"] = infer_strategy_bundles_for_diagnostic(diagnostic) + stop_condition = build_weakest_pack_stop_condition( + world_metrics=world_metrics, + issue_mix=issue_mix, + weakest_dimensions=weakest_dimensions, + ) + diagnostic["stop_condition"] = stop_condition + diagnostic["polish_bundle"] = build_weakest_pack_polish_bundle( + diagnostic=diagnostic, + stop_condition=stop_condition, + ) + return diagnostic -def build_long_route_summary(worlds: Sequence[Dict[str, Any]]) -> Dict[str, Any]: - if not worlds: - return { - "target_chapters": 0, - "avg_completion_ratio": 0.0, - "avg_mid_arc_drop": 0.0, - "avg_repetition_score": 0.0, - "avg_exposition_ratio": 0.0, - "packs_reaching_target": [], - "premature_ending_packs": [], - "stop_reason_counts": {}, +def build_weakest_pack_stop_condition( + *, + world_metrics: Dict[str, Any], + issue_mix: Sequence[Dict[str, Any]], + weakest_dimensions: Sequence[Dict[str, Any]], +) -> Dict[str, Any]: + total_issue_count = sum(int(item.get("count", 0)) for item in issue_mix) + checks = [ + { + "name": "issue_mix_clean", + "passed": total_issue_count == 0, + "actual": int(total_issue_count), + "target": 0, + }, + { + "name": "pass_rate", + "passed": float(world_metrics.get("pass_rate", 0.0)) >= float(POLISH_STOP_THRESHOLDS["pass_rate_min"]), + "actual": round(float(world_metrics.get("pass_rate", 0.0)), 3), + "target": float(POLISH_STOP_THRESHOLDS["pass_rate_min"]), + }, + { + "name": "block_rate", + "passed": float(world_metrics.get("block_rate", 0.0)) <= float(POLISH_STOP_THRESHOLDS["block_rate_max"]), + "actual": round(float(world_metrics.get("block_rate", 0.0)), 3), + "target": float(POLISH_STOP_THRESHOLDS["block_rate_max"]), + }, + { + "name": "long_route_quality", + "passed": float(world_metrics.get("long_route_quality", 0.0)) >= float(POLISH_STOP_THRESHOLDS["long_route_quality_min"]), + "actual": round(float(world_metrics.get("long_route_quality", 0.0)), 3), + "target": float(POLISH_STOP_THRESHOLDS["long_route_quality_min"]), + }, + { + "name": "scene_detail_density", + "passed": float(world_metrics.get("scene_detail_density", 0.0)) >= float(POLISH_STOP_THRESHOLDS["scene_detail_density_min"]), + "actual": round(float(world_metrics.get("scene_detail_density", 0.0)), 3), + "target": float(POLISH_STOP_THRESHOLDS["scene_detail_density_min"]), + }, + { + "name": "dialogue_distinctness", + "passed": float(world_metrics.get("dialogue_distinctness", 0.0)) >= float(POLISH_STOP_THRESHOLDS["dialogue_distinctness_min"]), + "actual": round(float(world_metrics.get("dialogue_distinctness", 0.0)), 3), + "target": float(POLISH_STOP_THRESHOLDS["dialogue_distinctness_min"]), + }, + { + "name": "dialogue_ratio", + "passed": float(world_metrics.get("dialogue_ratio", 0.0)) >= float(POLISH_STOP_THRESHOLDS["dialogue_ratio_min"]), + "actual": round(float(world_metrics.get("dialogue_ratio", 0.0)), 3), + "target": float(POLISH_STOP_THRESHOLDS["dialogue_ratio_min"]), + }, + { + "name": "diagnostic_score", + "passed": float(world_metrics.get("diagnostic_score", 0.0)) <= float(POLISH_STOP_THRESHOLDS["diagnostic_score_max"]), + "actual": round(float(world_metrics.get("diagnostic_score", 0.0)), 3), + "target": float(POLISH_STOP_THRESHOLDS["diagnostic_score_max"]), + }, + ] + failed_checks = [item["name"] for item in checks if not item["passed"]] + status = "stop_ready" if not failed_checks else "continue_polish" + rationale = ( + "当前 weakest-pack polish 已达到可暂停观察状态。" + if status == "stop_ready" + else "当前 weakest-pack 仍有结构性或指标性缺口,建议继续 polish。" + ) + return { + "status": status, + "failed_checks": failed_checks, + "checks": checks, + "thresholds": dict(POLISH_STOP_THRESHOLDS), + "rationale": rationale, + "weakest_dimensions": [str(item.get("name", "")) for item in weakest_dimensions], + } + + +def build_weakest_pack_polish_bundle( + *, + diagnostic: Dict[str, Any], + stop_condition: Dict[str, Any], +) -> Dict[str, Any]: + next_fix_candidates = list(diagnostic.get("next_fix_candidates", [])) + asset_snapshot = dict(diagnostic.get("asset_snapshot", {})) + target_dimensions = list(stop_condition.get("weakest_dimensions", [])) + bundle_items = [ + { + "priority": int(item.get("priority", 0)), + "module": item.get("module", ""), + "asset": item.get("asset", ""), + "policy": item.get("policy", ""), + "signal_score": item.get("signal_score", 0.0), + "suggested_action": item.get("suggested_action", ""), } - target = int(worlds[0].get("route_longevity_target", 0)) - stop_reason_counts: Dict[str, int] = {} - for item in worlds: - stop_reason = str(item.get("stop_reason", "unknown")) - stop_reason_counts[stop_reason] = stop_reason_counts.get(stop_reason, 0) + 1 + for item in next_fix_candidates[:3] + ] return { - "target_chapters": target, - "avg_completion_ratio": round(_average([float(item.get("completion_ratio", 0.0)) for item in worlds]), 3), - "avg_mid_arc_drop": round(_average([float(item.get("mid_arc_drop", 0.0)) for item in worlds]), 3), - "avg_repetition_score": round( - _average([float(item.get("avg_repetition_score", 0.0)) for item in worlds]), - 3, - ), - "avg_exposition_ratio": round( - _average([float(item.get("avg_exposition_ratio", 0.0)) for item in worlds]), - 3, + "bundle_status": stop_condition.get("status"), + "world_id": diagnostic.get("world_id", ""), + "target_dimensions": target_dimensions, + "primary_module": bundle_items[0]["module"] if bundle_items else "", + "primary_assets": [item["asset"] for item in bundle_items if item.get("asset")], + "primary_policies": [item["policy"] for item in bundle_items if item.get("policy")], + "bundle_items": bundle_items, + "asset_snapshot": asset_snapshot, + "recommended_action": ( + "pause_and_watch" + if stop_condition.get("status") == "stop_ready" + else "continue_targeted_polish" ), - "packs_reaching_target": [ - item.get("world_id", "-") - for item in worlds - if int(item.get("route_longevity", 0)) >= target + } + + +def build_weakest_pack_polish_program(weakest_pack_diagnostics: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + diagnostics = [dict(item) for item in weakest_pack_diagnostics] + stop_ready_worlds = [ + item.get("world_id", "") + for item in diagnostics + if dict(item.get("stop_condition", {})).get("status") == "stop_ready" + ] + continue_worlds = [ + item.get("world_id", "") + for item in diagnostics + if dict(item.get("stop_condition", {})).get("status") != "stop_ready" + ] + return { + "status": "stop_ready" if not continue_worlds else "continue_polish", + "stop_ready_worlds": stop_ready_worlds, + "continue_worlds": continue_worlds, + "recommended_action": "pause_lane_a_weakest_pack_polish" if not continue_worlds else "continue_lane_a_weakest_pack_polish", + "bundles": [dict(item.get("polish_bundle", {})) for item in diagnostics], + } + + +def build_longform_l1_signoff(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + weakest_program = dict(summary.get("weakest_pack_polish_program", {})) + longform_gate = dict(summary.get("longform_gate", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + + if benchmark_mode != "longform_100": + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_100", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_100_benchmark", + "confirm_weakest_pack_polish_program", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not generated_at: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_generated_at_missing", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "benchmark_generated_at", + "fresh_longform_100_benchmark", + ], + "generated_at": None, + "evidence_age_hours": None, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if evidence_age_hours is not None and evidence_age_hours > LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_signoff_stale", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "fresh_longform_100_benchmark", + "reconfirm_weakest_pack_polish_program", + ], + "generated_at": generated_at, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_benchmark_worldpack_all", + "confirm_all_benchmark_worlds_covered", + ], + "generated_at": generated_at, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + failed_gate_worlds = list(longform_gate.get("failed_worlds", [])) + continue_worlds = list(weakest_program.get("continue_worlds", [])) + blocking_worlds = sorted({world_id for world_id in failed_gate_worlds + continue_worlds if world_id}) + ready = not blocking_worlds and float(longform_gate.get("pass_rate", 0.0)) >= 1.0 + return { + "status": "ready" if ready else "blocked", + "ready": ready, + "reason": "longform_l1_signoff_ready" if ready else "longform_l1_signoff_blocked", + "blocking_worlds": blocking_worlds, + "watch_worlds": [] if ready else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in blocking_worlds], + "required_evidence": [ + "longform_100_gate_pass_rate=1.0", + "weakest_pack_polish_program.stop_ready", + "no_blocking_worlds", ], - "premature_ending_packs": [ - item.get("world_id", "-") - for item in worlds - if bool(item.get("premature_ending", False)) + "generated_at": generated_at, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + + +def build_interactive_longform_signoff(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + weakest_program = dict(summary.get("weakest_pack_polish_program", {})) + interactive_gate = dict(summary.get("interactive_longform_gate", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + if benchmark_mode != "longform_100_interactive": + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_100_interactive", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_100_interactive_benchmark", + "confirm_interactive_gate", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if evidence_age_hours is not None and evidence_age_hours > LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS: + return { + "status": "watch", + "ready": False, + "reason": "interactive_benchmark_signoff_stale", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "fresh_longform_100_interactive_benchmark", + "reconfirm_interactive_gate", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "interactive_benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_interactive_benchmark_worldpack_all", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + failed_gate_worlds = list(interactive_gate.get("failed_worlds", [])) + continue_worlds = list(weakest_program.get("continue_worlds", [])) + blocking_worlds = sorted({world_id for world_id in failed_gate_worlds + continue_worlds if world_id}) + ready = not blocking_worlds and float(interactive_gate.get("pass_rate", 0.0)) >= 1.0 + return { + "status": "ready" if ready else "blocked", + "ready": ready, + "reason": "interactive_longform_signoff_ready" if ready else "interactive_longform_signoff_blocked", + "blocking_worlds": blocking_worlds, + "watch_worlds": [] if ready else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in blocking_worlds], + "required_evidence": [ + "interactive_longform_gate_pass_rate=1.0", + "weakest_pack_polish_program.stop_ready", + "interactive_no_blocking_worlds", ], - "stop_reason_counts": stop_reason_counts, + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, } -def rank_weakest_packs(worlds: Iterable[Dict[str, Any]], *, limit: int = 3) -> List[Dict[str, Any]]: - ranked = sorted( - assign_diagnostic_ranks(worlds), - key=lambda item: ( - int(item.get("diagnostic_rank", 0)), - str(item.get("world_id", "")), - ), +def build_longform_250_signoff(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + weakest_program = dict(summary.get("weakest_pack_polish_program", {})) + longform_250_evidence = dict(summary.get("longform_250_evidence", {})) + review_sample_coverage_250 = dict(summary.get("review_sample_coverage_250", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + if benchmark_mode != "longform_250": + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_250", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_250_benchmark", + "review_sample_coverage_250", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": ["run_benchmark_worldpack_all"], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + continue_worlds = list(weakest_program.get("continue_worlds", [])) + evidence_failed_worlds = list(longform_250_evidence.get("failed_worlds", [])) + blocking_worlds = sorted({world_id for world_id in continue_worlds + evidence_failed_worlds if world_id}) + review_closeout_ready = bool( + longform_250_evidence.get("review_sample_closeout_ready", review_sample_coverage_250.get("closeout_ready", False)) ) - return [_pack_summary(item) for item in ranked[:limit]] + ready = ( + not blocking_worlds + and float(longform_250_evidence.get("gate_pass_rate", 0.0) or 0.0) >= 1.0 + and review_closeout_ready + ) + return { + "status": "ready" if ready else "watch", + "ready": ready, + "reason": "longform_250_signoff_ready" if ready else "longform_250_signoff_watch", + "blocking_worlds": blocking_worlds, + "watch_worlds": [] if ready else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in blocking_worlds], + "required_evidence": [ + "fresh_longform_250_benchmark", + "review_sample_coverage_250", + "weakest_pack_polish_program.stop_ready", + ], + "review_sample_closeout_ready": review_closeout_ready, + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } -def rank_strongest_packs(worlds: Iterable[Dict[str, Any]], *, limit: int = 2) -> List[Dict[str, Any]]: - ranked = sorted( - assign_diagnostic_ranks(worlds), - key=lambda item: ( - float(item.get("diagnostic_score", 0.0)), - -float(item.get("pass_rate", 0.0)), - float(item.get("block_rate", 0.0)), - -float(item.get("long_route_quality", 0.0)), - float(item.get("mid_arc_drop", 0.0)), - -float(item.get("dialogue_distinctness", 0.0)), - str(item.get("world_id", "")), - ), +def build_longform_250_interactive_signoff(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + weakest_program = dict(summary.get("weakest_pack_polish_program", {})) + longform_250_evidence = dict(summary.get("longform_250_evidence", {})) + interactive_gate = dict(summary.get("longform_250_interactive_gate", {})) + review_sample_coverage_250 = dict(summary.get("review_sample_coverage_250", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + if benchmark_mode != "longform_250_interactive": + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_250_interactive", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_250_interactive_benchmark", + "review_sample_coverage_250", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "interactive_benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": ["run_interactive_benchmark_worldpack_all"], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + continue_worlds = list(weakest_program.get("continue_worlds", [])) + static_failed_worlds = list(longform_250_evidence.get("failed_worlds", [])) + interactive_failed_worlds = list(interactive_gate.get("failed_worlds", [])) + blocking_worlds = sorted({world_id for world_id in continue_worlds + static_failed_worlds + interactive_failed_worlds if world_id}) + review_closeout_ready = bool( + longform_250_evidence.get("review_sample_closeout_ready", review_sample_coverage_250.get("closeout_ready", False)) ) - return [_pack_summary(item) for item in ranked[:limit]] + ready = ( + not blocking_worlds + and float(longform_250_evidence.get("gate_pass_rate", 0.0) or 0.0) >= 1.0 + and float(interactive_gate.get("pass_rate", 0.0) or 0.0) >= 1.0 + and review_closeout_ready + ) + return { + "status": "ready" if ready else "watch", + "ready": ready, + "reason": "longform_250_interactive_signoff_ready" if ready else "longform_250_interactive_signoff_watch", + "blocking_worlds": blocking_worlds, + "watch_worlds": [] if ready else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in blocking_worlds], + "required_evidence": [ + "fresh_longform_250_interactive_benchmark", + "longform_250_gate_pass_rate=1.0", + "longform_250_interactive_gate_pass_rate=1.0", + "review_sample_coverage_250", + "weakest_pack_polish_program.stop_ready", + ], + "review_sample_closeout_ready": review_closeout_ready, + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } -def benchmark_delta_report(current: Dict[str, object], baseline: Dict[str, object]) -> Dict[str, object]: - current_worlds = { - item["world_id"]: _enrich_world_metrics(item) for item in current.get("worlds", []) +def build_longform_250_human_review_closeout(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + review_sample_coverage_250 = dict(summary.get("review_sample_coverage_250", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + if benchmark_mode not in {"longform_250", "longform_250_interactive"}: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_250_family", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_250_benchmark", + "submit_human_review_samples_for_250_windows", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": ["run_benchmark_worldpack_all"], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + human_closeout_ready = bool(review_sample_coverage_250.get("human_closeout_ready", False)) + human_unreviewed_targets = list(review_sample_coverage_250.get("human_unreviewed_targets", [])) + blocking_worlds = sorted( + { + str(item.get("world_id") or "") + for item in human_unreviewed_targets + if str(item.get("world_id") or "") + } + ) + return { + "status": "ready" if human_closeout_ready else "watch", + "ready": human_closeout_ready, + "reason": "longform_250_human_review_closeout_ready" if human_closeout_ready else "longform_250_human_review_closeout_watch", + "blocking_worlds": blocking_worlds, + "watch_worlds": [] if human_closeout_ready else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in blocking_worlds], + "required_evidence": [ + "review_sample_coverage_250.human_closeout_ready", + "30_human_review_targets_closed", + ], + "human_closeout_status": review_sample_coverage_250.get("human_closeout_status"), + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, } - baseline_worlds = { - item["world_id"]: _enrich_world_metrics(item) for item in baseline.get("worlds", []) + + +def build_longform_500_signoff(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + weakest_program = dict(summary.get("weakest_pack_polish_program", {})) + longform_500_evidence = dict(summary.get("longform_500_evidence", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + if benchmark_mode != "longform_500": + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_500", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_500_benchmark", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": ["run_benchmark_worldpack_all"], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + continue_worlds = list(weakest_program.get("continue_worlds", [])) + failed_worlds = list(longform_500_evidence.get("failed_worlds", [])) + blocking_worlds = sorted({world_id for world_id in continue_worlds + failed_worlds if world_id}) + ready = not blocking_worlds and float(longform_500_evidence.get("gate_pass_rate", 0.0) or 0.0) >= 1.0 + return { + "status": "ready" if ready else "watch", + "ready": ready, + "reason": "longform_500_signoff_ready" if ready else "longform_500_signoff_watch", + "blocking_worlds": blocking_worlds, + "watch_worlds": [] if ready else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in blocking_worlds], + "required_evidence": [ + "fresh_longform_500_benchmark", + "weakest_pack_polish_program.stop_ready", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, } - world_deltas = { - world_id: {f"{metric}_delta": _metric_delta(current_worlds.get(world_id, {}), baseline_worlds.get(world_id, {}), metric) for metric in DELTA_METRICS} - for world_id in sorted(set(current_worlds) | set(baseline_worlds)) + + +def build_longform_500_human_review_closeout(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + review_sample_coverage_500 = dict(summary.get("review_sample_coverage_500", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + if benchmark_mode not in {"longform_500", "longform_500_interactive"}: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_500_family", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_500_benchmark", + "submit_human_review_samples_for_500_windows", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": ["run_benchmark_worldpack_all"], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + human_closeout_ready = bool(review_sample_coverage_500.get("human_closeout_ready", False)) + human_unreviewed_targets = list(review_sample_coverage_500.get("human_unreviewed_targets", [])) + blocking_worlds = sorted( + { + str(item.get("world_id") or "") + for item in human_unreviewed_targets + if str(item.get("world_id") or "") + } + ) + return { + "status": "ready" if human_closeout_ready else "watch", + "ready": human_closeout_ready, + "reason": "longform_500_human_review_closeout_ready" if human_closeout_ready else "longform_500_human_review_closeout_watch", + "blocking_worlds": blocking_worlds, + "watch_worlds": [] if human_closeout_ready else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in blocking_worlds], + "required_evidence": [ + "review_sample_coverage_500.human_closeout_ready", + "30_human_review_targets_closed", + ], + "human_closeout_status": review_sample_coverage_500.get("human_closeout_status"), + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, } - regressions: List[Dict[str, object]] = [] - for world_id, delta in world_deltas.items(): - if world_id not in current_worlds or world_id not in baseline_worlds: - continue - current_world = current_worlds.get(world_id, {}) - baseline_world = baseline_worlds.get(world_id, {}) - regressed_metrics = [ - metric_name.removesuffix("_delta") - for metric_name, value in delta.items() - if metric_name.removesuffix("_delta") in baseline_world - if (metric_name in {"pass_rate_delta", "character_fidelity_delta", "causal_continuity_delta", "choice_distinctness_delta", "route_longevity_delta", "dialogue_ratio_delta", "scene_detail_density_delta", "voice_separation_score_delta", "emotion_action_specificity_delta"} and value < 0) - or (metric_name == "prose_leak_rate_delta" and value > 0) - or (metric_name == "block_rate_delta" and value > 0) - or (metric_name == "long_route_quality_delta" and value < 0) - or (metric_name == "mid_arc_drop_delta" and value > 0) - or (metric_name == "dialogue_distinctness_delta" and value < 0) - or (metric_name == "completion_ratio_delta" and value < 0) - or (metric_name == "avg_overall_score_delta" and value < 0) - or (metric_name == "mid_arc_pass_rate_delta" and value < 0) - or (metric_name == "late_arc_pass_rate_delta" and value < 0) - or (metric_name == "avg_repetition_score_delta" and value > 0) - or (metric_name == "avg_exposition_ratio_delta" and value > 0) - or (metric_name == "avg_hook_quality_delta" and value < 0) - or (metric_name == "diagnostic_score_delta" and value > 0) - ] - if "choice_distinctness" in regressed_metrics and float(current_world.get("choice_distinctness", 0.0)) >= 0.8: - regressed_metrics.remove("choice_distinctness") - if "scene_detail_density" in regressed_metrics and abs(float(delta.get("scene_detail_density_delta", 0.0))) <= 0.002: - regressed_metrics.remove("scene_detail_density") - if "dialogue_ratio" in regressed_metrics and float(current_world.get("dialogue_ratio", 0.0)) >= 0.3 and abs(float(delta.get("dialogue_ratio_delta", 0.0))) <= 0.05: - regressed_metrics.remove("dialogue_ratio") - if "long_route_quality" in regressed_metrics and abs(float(delta.get("long_route_quality_delta", 0.0))) <= 0.01: - regressed_metrics.remove("long_route_quality") - if "avg_overall_score" in regressed_metrics and abs(float(delta.get("avg_overall_score_delta", 0.0))) <= 0.01: - regressed_metrics.remove("avg_overall_score") - if ( - "avg_repetition_score" in regressed_metrics - and float(current_world.get("avg_repetition_score", 0.0)) <= 0.08 - and abs(float(delta.get("avg_repetition_score_delta", 0.0))) <= 0.04 - ): - regressed_metrics.remove("avg_repetition_score") - if ( - "avg_exposition_ratio" in regressed_metrics + + +def build_longform_500_ending_signoff(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + longform_500_evidence = dict(summary.get("longform_500_evidence", {})) + review_sample_coverage_500 = dict(summary.get("review_sample_coverage_500", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + if benchmark_mode not in {"longform_500", "longform_500_interactive"}: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_500_family", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_500_benchmark", + "review_sample_coverage_500.ending_window_human_closeout_ready", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": ["run_benchmark_worldpack_all"], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + ready = bool( + float(longform_500_evidence.get("gate_pass_rate", 0.0) or 0.0) >= 1.0 + and bool(review_sample_coverage_500.get("ending_window_human_closeout_ready", False)) + ) + blocking_worlds = [] if ready else sorted( + { + str(item.get("world_id") or "") + for item in review_sample_coverage_500.get("human_unreviewed_targets", []) + if str(item.get("world_id") or "") and str(item.get("window_label") or "") == str(review_sample_coverage_500.get("ending_window_label") or "") + } + ) + return { + "status": "ready" if ready else "watch", + "ready": ready, + "reason": "longform_500_ending_signoff_ready" if ready else "longform_500_ending_signoff_watch", + "blocking_worlds": blocking_worlds, + "watch_worlds": [] if ready else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in blocking_worlds], + "required_evidence": [ + "longform_500_signoff.ready", + "review_sample_coverage_500.ending_window_human_closeout_ready", + "series_ending_control_score=1.0", + ], + "ending_window_label": review_sample_coverage_500.get("ending_window_label"), + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + + +def build_longform_500_interactive_signoff(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + weakest_program = dict(summary.get("weakest_pack_polish_program", {})) + longform_500_evidence = dict(summary.get("longform_500_evidence", {})) + interactive_gate = dict(summary.get("longform_500_interactive_gate", {})) + review_sample_coverage_500 = dict(summary.get("review_sample_coverage_500", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + if benchmark_mode != "longform_500_interactive": + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_500_interactive", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_500_interactive_benchmark", + "review_sample_coverage_500", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "interactive_benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": ["run_interactive_benchmark_worldpack_all"], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + continue_worlds = list(weakest_program.get("continue_worlds", [])) + static_failed_worlds = list(longform_500_evidence.get("failed_worlds", [])) + interactive_failed_worlds = list(interactive_gate.get("failed_worlds", [])) + blocking_worlds = sorted({world_id for world_id in continue_worlds + static_failed_worlds + interactive_failed_worlds if world_id}) + review_closeout_ready = bool(review_sample_coverage_500.get("human_closeout_ready", False)) + ending_closeout_ready = bool(review_sample_coverage_500.get("ending_window_human_closeout_ready", False)) + ready = ( + not blocking_worlds + and float(longform_500_evidence.get("gate_pass_rate", 0.0) or 0.0) >= 1.0 + and float(interactive_gate.get("pass_rate", 0.0) or 0.0) >= 1.0 + and review_closeout_ready + and ending_closeout_ready + ) + return { + "status": "ready" if ready else "watch", + "ready": ready, + "reason": "longform_500_interactive_signoff_ready" if ready else "longform_500_interactive_signoff_watch", + "blocking_worlds": blocking_worlds, + "watch_worlds": [] if ready else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in blocking_worlds], + "required_evidence": [ + "fresh_longform_500_interactive_benchmark", + "longform_500_gate_pass_rate=1.0", + "longform_500_interactive_gate_pass_rate=1.0", + "review_sample_coverage_500.human_closeout_ready", + "review_sample_coverage_500.ending_window_human_closeout_ready", + "weakest_pack_polish_program.stop_ready", + ], + "human_closeout_ready": review_closeout_ready, + "ending_window_human_closeout_ready": ending_closeout_ready, + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + + +def build_longform_1000_feasibility(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + longform_1000_evidence = dict(summary.get("longform_1000_evidence", {})) + longform_1000_summary = dict(summary.get("longform_1000_summary", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + if benchmark_mode not in {"longform_1000_diagnostics", "longform_1000_interactive"}: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_1000_family", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_1000_diagnostics_benchmark", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": ["run_benchmark_worldpack_all"], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + failed_worlds = list(longform_1000_evidence.get("failed_worlds", [])) + promising = not failed_worlds and float(longform_1000_evidence.get("diagnostic_pass_rate", 0.0) or 0.0) >= 1.0 + return { + "status": "promising" if promising else "watch", + "ready": promising, + "reason": "longform_1000_feasibility_promising" if promising else "longform_1000_feasibility_watch", + "blocking_worlds": list(failed_worlds), + "watch_worlds": [] if promising else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in failed_worlds], + "required_evidence": [ + "series_memory_snapshot_integrity=1.0", + "archive_retention_integrity=1.0", + "continuation_state_retention_integrity=1.0", + "late_stage_runtime_budget_score>=0.67", + ], + "diagnostic_pass_rate": longform_1000_summary.get("diagnostic_pass_rate"), + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + + +def build_longform_1000_readiness(summary: Dict[str, Any]) -> Dict[str, Any]: + feasibility = dict(build_longform_1000_feasibility(summary)) + generated_at = str(summary.get("generated_at") or "") + evidence_age_hours = feasibility.get("evidence_age_hours") + if str(feasibility.get("status") or "watch") == "watch" and not feasibility.get("ready", False): + return { + "status": "watch", + "ready": False, + "reason": "longform_1000_readiness_watch", + "blocking_worlds": list(feasibility.get("blocking_worlds", [])), + "watch_worlds": list(feasibility.get("watch_worlds", [])), + "required_evidence": [ + "longform_1000_feasibility.ready", + "fresh_longform_1000_diagnostics_benchmark", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + return { + "status": "ready", + "ready": True, + "reason": "longform_1000_readiness_ready", + "blocking_worlds": [], + "watch_worlds": [], + "required_evidence": [ + "longform_1000_feasibility.ready", + "diagnostic_pass_rate=1.0", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + + +def build_longform_1000_human_review_closeout(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + review_sample_coverage_1000 = dict(summary.get("review_sample_coverage_1000", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + if benchmark_mode not in {"longform_1000_diagnostics", "longform_1000_interactive"}: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_1000_family", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_1000_diagnostics_benchmark", + "submit_human_review_samples_for_1000_windows", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": ["run_benchmark_worldpack_all"], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + human_closeout_ready = bool(review_sample_coverage_1000.get("human_closeout_ready", False)) + human_unreviewed_targets = list(review_sample_coverage_1000.get("human_unreviewed_targets", [])) + blocking_worlds = sorted( + { + str(item.get("world_id") or "") + for item in human_unreviewed_targets + if str(item.get("world_id") or "") + } + ) + return { + "status": "ready" if human_closeout_ready else "watch", + "ready": human_closeout_ready, + "reason": "longform_1000_human_review_closeout_ready" if human_closeout_ready else "longform_1000_human_review_closeout_watch", + "blocking_worlds": blocking_worlds, + "watch_worlds": [] if human_closeout_ready else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in blocking_worlds], + "required_evidence": [ + "review_sample_coverage_1000.human_closeout_ready", + "6_human_review_targets_closed", + ], + "human_closeout_status": review_sample_coverage_1000.get("human_closeout_status"), + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + + +def build_longform_1000_interactive_signoff(summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(summary.get("benchmark_mode", "standard") or "standard") + readiness = dict(summary.get("longform_1000_readiness") or build_longform_1000_readiness(summary)) + interactive_gate = dict(summary.get("longform_1000_interactive_gate", {})) + weakest_packs = list(summary.get("weakest_packs", [])) + generated_at = str(summary.get("generated_at") or "") + benchmark_scope_complete = bool(summary.get("benchmark_scope_complete", False)) + evidence_age_hours = None + if generated_at: + try: + evidence_time = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + evidence_age_hours = round((datetime.now(timezone.utc) - evidence_time).total_seconds() / 3600.0, 3) + except ValueError: + evidence_age_hours = None + if benchmark_mode != "longform_1000_interactive": + return { + "status": "watch", + "ready": False, + "reason": "benchmark_mode_not_longform_1000_interactive", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": [ + "run_longform_1000_interactive_benchmark", + "longform_1000_readiness.ready", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + if not benchmark_scope_complete: + return { + "status": "watch", + "ready": False, + "reason": "interactive_benchmark_scope_incomplete", + "blocking_worlds": [], + "watch_worlds": [item.get("world_id", "") for item in weakest_packs], + "required_evidence": ["run_interactive_benchmark_worldpack_all"], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + failed_worlds = list(interactive_gate.get("failed_worlds", [])) + blocking_worlds = sorted({world_id for world_id in list(readiness.get("blocking_worlds", [])) + failed_worlds if world_id}) + ready = bool(readiness.get("ready", False)) and float(interactive_gate.get("pass_rate", 0.0) or 0.0) >= 1.0 and not blocking_worlds + return { + "status": "ready" if ready else "watch", + "ready": ready, + "reason": "longform_1000_interactive_signoff_ready" if ready else "longform_1000_interactive_signoff_watch", + "blocking_worlds": blocking_worlds, + "watch_worlds": [] if ready else [item.get("world_id", "") for item in weakest_packs if item.get("world_id") not in blocking_worlds], + "required_evidence": [ + "longform_1000_readiness.ready", + "fresh_longform_1000_interactive_benchmark", + "longform_1000_interactive_gate_pass_rate=1.0", + ], + "generated_at": generated_at or None, + "evidence_age_hours": evidence_age_hours, + "freshness_threshold_hours": LONGFORM_L1_SIGNOFF_MAX_AGE_HOURS, + } + + +def build_character_fidelity_remediation_framework(summary: Dict[str, Any]) -> Dict[str, Any]: + framework = dict(summary.get("character_fidelity_remediation_framework", {})) + worlds = list(framework.get("q06_worlds", [])) + if not worlds: + return { + "available": False, + "status": "clear", + "q06_world_count": 0, + "top_worlds": [], + "top_characters": [], + "top_duties": [], + "recommended_assets": [], + "next_actions": ["character_fidelity_stable"], + } + + character_counts: Dict[str, Dict[str, Any]] = {} + duty_counts: Dict[str, Dict[str, Any]] = {} + for world_payload in worlds: + world_id = str(world_payload.get("world_id") or "") + world_framework = dict(world_payload.get("framework") or {}) + for item in world_framework.get("top_character_hotspots", []): + character_id = str(item.get("character_id") or "") + if not character_id: + continue + entry = character_counts.setdefault( + character_id, + {"character_id": character_id, "world_ids": set(), "count": 0, "lowest_fidelity": 1.0}, + ) + entry["world_ids"].add(world_id) + entry["count"] += int(item.get("count", 0) or 0) + entry["lowest_fidelity"] = min(float(entry["lowest_fidelity"]), float(item.get("lowest_fidelity", 1.0) or 1.0)) + for item in world_framework.get("top_duty_hotspots", []): + duty_type = str(item.get("duty_type") or "") + if not duty_type: + continue + entry = duty_counts.setdefault( + duty_type, + {"duty_type": duty_type, "world_ids": set(), "count": 0, "lowest_fidelity": 1.0}, + ) + entry["world_ids"].add(world_id) + entry["count"] += int(item.get("count", 0) or 0) + entry["lowest_fidelity"] = min(float(entry["lowest_fidelity"]), float(item.get("lowest_fidelity", 1.0) or 1.0)) + + ranked_worlds = sorted( + worlds, + key=lambda item: ( + -float(item.get("q06_issue_share", 0.0) or 0.0), + float(item.get("character_fidelity", 1.0) or 1.0), + str(item.get("world_id") or ""), + ), + ) + ranked_characters = sorted( + character_counts.values(), + key=lambda item: (-int(item["count"]), float(item["lowest_fidelity"]), str(item["character_id"])), + ) + ranked_duties = sorted( + duty_counts.values(), + key=lambda item: (-int(item["count"]), float(item["lowest_fidelity"]), str(item["duty_type"])), + ) + return { + "available": True, + "status": "active", + "q06_world_count": len(worlds), + "top_worlds": ranked_worlds[:5], + "top_characters": [ + { + "character_id": item["character_id"], + "count": int(item["count"]), + "lowest_fidelity": round(float(item["lowest_fidelity"]), 3), + "world_ids": sorted(item["world_ids"]), + } + for item in ranked_characters[:8] + ], + "top_duties": [ + { + "duty_type": item["duty_type"], + "count": int(item["count"]), + "lowest_fidelity": round(float(item["lowest_fidelity"]), 3), + "world_ids": sorted(item["world_ids"]), + } + for item in ranked_duties[:8] + ], + "recommended_assets": list(framework.get("recommended_assets", [])), + "next_actions": [ + "tighten_character_cards", + "tighten_emotion_action_policies", + "inspect_q06_priority_chapters", + ], + } + + +def build_character_fidelity_remediation_framework(summary: Dict[str, Any]) -> Dict[str, Any]: + framework = dict(summary.get("character_fidelity_remediation_framework", {})) + worlds = list(framework.get("q06_worlds", [])) + if not worlds: + return { + "available": False, + "status": "clear", + "q06_world_count": 0, + "top_worlds": [], + "top_characters": [], + "top_duties": [], + "recommended_assets": [], + "next_actions": ["character_fidelity_stable"], + } + + character_counts: Dict[str, Dict[str, Any]] = {} + duty_counts: Dict[str, Dict[str, Any]] = {} + for world_payload in worlds: + world_id = str(world_payload.get("world_id") or "") + world_framework = dict(world_payload.get("framework") or {}) + for item in world_framework.get("top_character_hotspots", []): + character_id = str(item.get("character_id") or "") + if not character_id: + continue + entry = character_counts.setdefault( + character_id, + {"character_id": character_id, "world_ids": set(), "count": 0, "lowest_fidelity": 1.0}, + ) + entry["world_ids"].add(world_id) + entry["count"] += int(item.get("count", 0) or 0) + entry["lowest_fidelity"] = min(float(entry["lowest_fidelity"]), float(item.get("lowest_fidelity", 1.0) or 1.0)) + for item in world_framework.get("top_duty_hotspots", []): + duty_type = str(item.get("duty_type") or "") + if not duty_type: + continue + entry = duty_counts.setdefault( + duty_type, + {"duty_type": duty_type, "world_ids": set(), "count": 0, "lowest_fidelity": 1.0}, + ) + entry["world_ids"].add(world_id) + entry["count"] += int(item.get("count", 0) or 0) + entry["lowest_fidelity"] = min(float(entry["lowest_fidelity"]), float(item.get("lowest_fidelity", 1.0) or 1.0)) + + ranked_worlds = sorted( + worlds, + key=lambda item: ( + -float(item.get("q06_issue_share", 0.0) or 0.0), + float(item.get("character_fidelity", 1.0) or 1.0), + str(item.get("world_id") or ""), + ), + ) + ranked_characters = sorted( + character_counts.values(), + key=lambda item: (-int(item["count"]), float(item["lowest_fidelity"]), str(item["character_id"])), + ) + ranked_duties = sorted( + duty_counts.values(), + key=lambda item: (-int(item["count"]), float(item["lowest_fidelity"]), str(item["duty_type"])), + ) + return { + "available": True, + "status": "active", + "q06_world_count": len(worlds), + "top_worlds": ranked_worlds[:5], + "top_characters": [ + { + "character_id": item["character_id"], + "count": int(item["count"]), + "lowest_fidelity": round(float(item["lowest_fidelity"]), 3), + "world_ids": sorted(item["world_ids"]), + } + for item in ranked_characters[:8] + ], + "top_duties": [ + { + "duty_type": item["duty_type"], + "count": int(item["count"]), + "lowest_fidelity": round(float(item["lowest_fidelity"]), 3), + "world_ids": sorted(item["world_ids"]), + } + for item in ranked_duties[:8] + ], + "recommended_assets": list(framework.get("recommended_assets", [])), + "next_actions": [ + "tighten_character_cards", + "tighten_emotion_action_policies", + "inspect_q06_priority_chapters", + ], + } + + +def build_long_route_summary(worlds: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + if not worlds: + return { + "target_chapters": 0, + "avg_completion_ratio": 0.0, + "avg_mid_arc_drop": 0.0, + "avg_repetition_score": 0.0, + "avg_exposition_ratio": 0.0, + "packs_reaching_target": [], + "premature_ending_packs": [], + "stop_reason_counts": {}, + "q03_q09_calibration": {}, + } + target = int(worlds[0].get("route_longevity_target", 0)) + stop_reason_counts: Dict[str, int] = {} + q03_recommendation_counts: Dict[str, int] = {} + q09_recommendation_counts: Dict[str, int] = {} + q03_correlations: List[float] = [] + q09_correlations: List[float] = [] + coverage_insufficient_worlds: List[str] = [] + for item in worlds: + stop_reason = str(item.get("stop_reason", "unknown")) + stop_reason_counts[stop_reason] = stop_reason_counts.get(stop_reason, 0) + 1 + calibration = dict(item.get("continuation_calibration") or {}) + if calibration: + q03 = dict(calibration.get("q03") or {}) + q09 = dict(calibration.get("q09") or {}) + q03_recommendation = str(q03.get("recommendation") or "") + q09_recommendation = str(q09.get("recommendation") or "") + if q03_recommendation: + q03_recommendation_counts[q03_recommendation] = q03_recommendation_counts.get(q03_recommendation, 0) + 1 + if q09_recommendation: + q09_recommendation_counts[q09_recommendation] = q09_recommendation_counts.get(q09_recommendation, 0) + 1 + if q03.get("primary_correlation") is not None: + q03_correlations.append(float(q03.get("primary_correlation") or 0.0)) + if q09.get("primary_correlation") is not None: + q09_correlations.append(float(q09.get("primary_correlation") or 0.0)) + if str(calibration.get("coverage_status") or "") == "insufficient_coverage": + coverage_insufficient_worlds.append(str(item.get("world_id") or "-")) + return { + "target_chapters": target, + "avg_completion_ratio": round(_average([float(item.get("completion_ratio", 0.0)) for item in worlds]), 3), + "avg_mid_arc_drop": round(_average([float(item.get("mid_arc_drop", 0.0)) for item in worlds]), 3), + "avg_repetition_score": round( + _average([float(item.get("avg_repetition_score", 0.0)) for item in worlds]), + 3, + ), + "avg_exposition_ratio": round( + _average([float(item.get("avg_exposition_ratio", 0.0)) for item in worlds]), + 3, + ), + "packs_reaching_target": [ + item.get("world_id", "-") + for item in worlds + if int(item.get("route_longevity", 0)) >= target + ], + "premature_ending_packs": [ + item.get("world_id", "-") + for item in worlds + if bool(item.get("premature_ending", False)) + ], + "stop_reason_counts": stop_reason_counts, + "q03_q09_calibration": { + "coverage_insufficient_worlds": coverage_insufficient_worlds, + "q03_recommendation_counts": q03_recommendation_counts, + "q09_recommendation_counts": q09_recommendation_counts, + "avg_q03_primary_correlation": round(_average(q03_correlations), 3) if q03_correlations else 0.0, + "avg_q09_primary_correlation": round(_average(q09_correlations), 3) if q09_correlations else 0.0, + }, + } + + +def _interactive_issue_rate_average( + worlds: Sequence[Dict[str, Any]], + *, + window_key: str, + issue_code: str, +) -> float: + values: List[float] = [] + for item in worlds: + for scenario in item.get("post_steer_issue_window_summary", []) or []: + window = dict(scenario.get(window_key) or {}) + rates = dict(window.get("issue_rates") or {}) + if issue_code in rates: + values.append(float(rates.get(issue_code, 0.0) or 0.0)) + return round(_average(values), 3) if values else 0.0 + + +def build_interactive_long_route_summary( + worlds: Sequence[Dict[str, Any]], + *, + target_chapters: int, + interactive_profile: str, +) -> Dict[str, Any]: + if not worlds: + return { + "target_chapters": int(target_chapters), + "interactive_profile": interactive_profile, + "scenario_count": 0, + "steering_recovery_rate": 0.0, + "post_steer_route_survival": 0.0, + "memory_consistency_after_steer": 0.0, + "promise_reconciliation_after_steer": 0.0, + "replan_stability_score": 0.0, + "avg_short_window_issue_rates": { + issue_code: 0.0 for issue_code in INTERACTIVE_LONG_ROUTE_ISSUE_CODES + }, + "avg_long_window_issue_rates": { + issue_code: 0.0 for issue_code in INTERACTIVE_LONG_ROUTE_ISSUE_CODES + }, + "worlds_with_interactive_data": [], + } + interactive_worlds = [item for item in worlds if item.get("interactive_summary")] + source_worlds = interactive_worlds or list(worlds) + return { + "target_chapters": int(target_chapters), + "interactive_profile": interactive_profile, + "scenario_count": int( + round( + _average( + [ + float((item.get("interactive_summary") or {}).get("scenario_count", 0) or 0) + for item in source_worlds + ] + ) + ) + ), + "steering_recovery_rate": round( + _average([float((item.get("interactive_summary") or {}).get("steering_recovery_rate", 0.0) or 0.0) for item in source_worlds]), + 3, + ), + "post_steer_route_survival": round( + _average([float((item.get("interactive_summary") or {}).get("post_steer_route_survival", 0.0) or 0.0) for item in source_worlds]), + 3, + ), + "memory_consistency_after_steer": round( + _average([float((item.get("interactive_summary") or {}).get("memory_consistency_after_steer", 0.0) or 0.0) for item in source_worlds]), + 3, + ), + "promise_reconciliation_after_steer": round( + _average([float((item.get("interactive_summary") or {}).get("promise_reconciliation_after_steer", 0.0) or 0.0) for item in source_worlds]), + 3, + ), + "replan_stability_score": round( + _average([float((item.get("interactive_summary") or {}).get("replan_stability_score", 0.0) or 0.0) for item in source_worlds]), + 3, + ), + "avg_short_window_issue_rates": { + issue_code: _interactive_issue_rate_average(source_worlds, window_key="short_window", issue_code=issue_code) + for issue_code in INTERACTIVE_LONG_ROUTE_ISSUE_CODES + }, + "avg_long_window_issue_rates": { + issue_code: _interactive_issue_rate_average(source_worlds, window_key="long_window", issue_code=issue_code) + for issue_code in INTERACTIVE_LONG_ROUTE_ISSUE_CODES + }, + "worlds_with_interactive_data": [ + str(item.get("world_id") or "-") + for item in source_worlds + if item.get("interactive_summary") + ], + } + + +def build_content_quality_contract_summary(worlds: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + enabled_worlds = [ + dict(item) + for item in worlds + if dict(item.get("content_quality_contract_window_metrics") or {}).get("enabled") + ] + if not enabled_worlds: + return {} + first_coverage = dict(enabled_worlds[0].get("content_quality_contract_coverage") or {}) + first_window_metrics = dict(enabled_worlds[0].get("content_quality_contract_window_metrics") or {}) + inferred_gate_enforced = bool(first_window_metrics.get("gate_enforced", first_coverage.get("gate_enforced", False))) + inferred_diagnostic_enabled = ( + bool(first_window_metrics["diagnostic_enabled"]) + if "diagnostic_enabled" in first_window_metrics + else ( + bool(first_coverage["diagnostic_enabled"]) + if "diagnostic_enabled" in first_coverage and first_coverage.get("applicable") + else bool((first_window_metrics.get("band") or first_coverage.get("band")) and not inferred_gate_enforced) + ) + ) + return { + "band": first_window_metrics.get("band") or first_coverage.get("band"), + "config_version": first_window_metrics.get("config_version") or first_coverage.get("config_version"), + "gate_enforced": inferred_gate_enforced, + "diagnostic_enabled": inferred_diagnostic_enabled, + "applicable_world_count": len(enabled_worlds), + "avg_early_window_q03_q04_share": round( + _average( + [ + float((item.get("content_quality_contract_window_metrics") or {}).get("early_window_q03_q04_share", 0.0) or 0.0) + for item in enabled_worlds + ] + ), + 3, + ), + "avg_mid_window_repeat_breach_rate": round( + _average( + [ + float((item.get("content_quality_contract_window_metrics") or {}).get("mid_window_repeat_breach_rate", 0.0) or 0.0) + for item in enabled_worlds + ] + ), + 3, + ), + "avg_mid_window_exposition_breach_rate": round( + _average( + [ + float((item.get("content_quality_contract_window_metrics") or {}).get("mid_window_exposition_breach_rate", 0.0) or 0.0) + for item in enabled_worlds + ] + ), + 3, + ), + "avg_mid_window_detail_breach_rate": round( + _average( + [ + float((item.get("content_quality_contract_window_metrics") or {}).get("mid_window_detail_breach_rate", 0.0) or 0.0) + for item in enabled_worlds + ] + ), + 3, + ), + "avg_late_window_q09_breach_rate": round( + _average( + [ + float((item.get("content_quality_contract_window_metrics") or {}).get("late_window_q09_breach_rate", 0.0) or 0.0) + for item in enabled_worlds + ] + ), + 3, + ), + "avg_late_window_detail_breach_rate": round( + _average( + [ + float((item.get("content_quality_contract_window_metrics") or {}).get("late_window_detail_breach_rate", 0.0) or 0.0) + for item in enabled_worlds + ] + ), + 3, + ), + } + + +def rank_weakest_packs(worlds: Iterable[Dict[str, Any]], *, limit: int = 3) -> List[Dict[str, Any]]: + ranked = sorted( + assign_diagnostic_ranks(worlds), + key=lambda item: ( + int(item.get("diagnostic_rank", 0)), + str(item.get("world_id", "")), + ), + ) + return [_pack_summary(item) for item in ranked[:limit]] + + +def rank_strongest_packs(worlds: Iterable[Dict[str, Any]], *, limit: int = 2) -> List[Dict[str, Any]]: + ranked = sorted( + assign_diagnostic_ranks(worlds), + key=lambda item: ( + float(item.get("diagnostic_score", 0.0)), + -float(item.get("pass_rate", 0.0)), + float(item.get("block_rate", 0.0)), + -float(item.get("long_route_quality", 0.0)), + float(item.get("mid_arc_drop", 0.0)), + -float(item.get("dialogue_distinctness", 0.0)), + str(item.get("world_id", "")), + ), + ) + return [_pack_summary(item) for item in ranked[:limit]] + + +def benchmark_delta_report(current: Dict[str, object], baseline: Dict[str, object]) -> Dict[str, object]: + current_worlds = { + item["world_id"]: _enrich_world_metrics(item) for item in current.get("worlds", []) + } + baseline_worlds = { + item["world_id"]: _enrich_world_metrics(item) for item in baseline.get("worlds", []) + } + world_deltas = { + world_id: {f"{metric}_delta": _metric_delta(current_worlds.get(world_id, {}), baseline_worlds.get(world_id, {}), metric) for metric in DELTA_METRICS} + for world_id in sorted(set(current_worlds) | set(baseline_worlds)) + } + regressions: List[Dict[str, object]] = [] + for world_id, delta in world_deltas.items(): + if world_id not in current_worlds or world_id not in baseline_worlds: + continue + current_world = current_worlds.get(world_id, {}) + baseline_world = baseline_worlds.get(world_id, {}) + regressed_metrics = [ + metric_name.removesuffix("_delta") + for metric_name, value in delta.items() + if metric_name.removesuffix("_delta") in baseline_world + if (metric_name in {"pass_rate_delta", "character_fidelity_delta", "causal_continuity_delta", "choice_distinctness_delta", "route_longevity_delta", "dialogue_ratio_delta", "scene_detail_density_delta", "voice_separation_score_delta", "emotion_action_specificity_delta"} and value < 0) + or (metric_name == "prose_leak_rate_delta" and value > 0) + or (metric_name == "block_rate_delta" and value > 0) + or (metric_name == "long_route_quality_delta" and value < 0) + or (metric_name == "mid_arc_drop_delta" and value > 0) + or (metric_name == "dialogue_distinctness_delta" and value < 0) + or (metric_name == "completion_ratio_delta" and value < 0) + or (metric_name == "avg_overall_score_delta" and value < 0) + or (metric_name == "mid_arc_pass_rate_delta" and value < 0) + or (metric_name == "late_arc_pass_rate_delta" and value < 0) + or (metric_name == "avg_repetition_score_delta" and value > 0) + or (metric_name == "avg_exposition_ratio_delta" and value > 0) + or (metric_name == "avg_hook_quality_delta" and value < 0) + or (metric_name == "diagnostic_score_delta" and value > 0) + ] + if "choice_distinctness" in regressed_metrics and float(current_world.get("choice_distinctness", 0.0)) >= 0.8: + regressed_metrics.remove("choice_distinctness") + if "scene_detail_density" in regressed_metrics and abs(float(delta.get("scene_detail_density_delta", 0.0))) <= 0.002: + regressed_metrics.remove("scene_detail_density") + if "dialogue_ratio" in regressed_metrics and float(current_world.get("dialogue_ratio", 0.0)) >= 0.3 and abs(float(delta.get("dialogue_ratio_delta", 0.0))) <= 0.05: + regressed_metrics.remove("dialogue_ratio") + if "long_route_quality" in regressed_metrics and abs(float(delta.get("long_route_quality_delta", 0.0))) <= 0.01: + regressed_metrics.remove("long_route_quality") + if "avg_overall_score" in regressed_metrics and abs(float(delta.get("avg_overall_score_delta", 0.0))) <= 0.01: + regressed_metrics.remove("avg_overall_score") + if ( + "avg_repetition_score" in regressed_metrics + and float(current_world.get("avg_repetition_score", 0.0)) <= 0.1 + and abs(float(delta.get("avg_repetition_score_delta", 0.0))) <= 0.06 + ): + regressed_metrics.remove("avg_repetition_score") + if ( + "avg_exposition_ratio" in regressed_metrics and float(current_world.get("avg_exposition_ratio", 0.0)) <= 0.5 and abs(float(delta.get("avg_exposition_ratio_delta", 0.0))) <= 0.05 ): @@ -854,9 +2365,44 @@ def rank_top_failing_packs(worlds: Iterable[Dict[str, Any]], *, limit: int = 3) def render_benchmark_markdown(summary: Dict[str, Any]) -> str: weakest_packs = list(summary.get("weakest_packs", [])) weakest_pack_diagnostics = list(summary.get("weakest_pack_diagnostics", [])) + weakest_pack_polish_program = dict(summary.get("weakest_pack_polish_program", {})) + strategy_bundle_batch_validation = dict(summary.get("strategy_bundle_batch_validation") or {}) + strategy_bundle_batch_validation_history = dict(summary.get("strategy_bundle_batch_validation_history") or {}) + strategy_bundle_batch_validation_trend = dict(summary.get("strategy_bundle_batch_validation_trend") or {}) + longform_l1_signoff = dict(summary.get("longform_l1_signoff", {})) + interactive_longform_signoff = dict(summary.get("interactive_longform_signoff", {})) + longform_250_summary = dict(summary.get("longform_250_summary", {})) + longform_250_signoff = dict(summary.get("longform_250_signoff", {})) + longform_250_interactive_summary = dict(summary.get("longform_250_interactive_summary", {})) + longform_250_interactive_signoff = dict(summary.get("longform_250_interactive_signoff", {})) + longform_250_human_review_closeout = dict(summary.get("longform_250_human_review_closeout", {})) + longform_500_summary = dict(summary.get("longform_500_summary", {})) + longform_500_signoff = dict(summary.get("longform_500_signoff", {})) + longform_500_human_review_closeout = dict(summary.get("longform_500_human_review_closeout", {})) + longform_500_ending_signoff = dict(summary.get("longform_500_ending_signoff", {})) + longform_500_interactive_summary = dict(summary.get("longform_500_interactive_summary", {})) + longform_500_interactive_signoff = dict(summary.get("longform_500_interactive_signoff", {})) + longform_1000_summary = dict(summary.get("longform_1000_summary", {})) + longform_1000_readiness = dict(summary.get("longform_1000_readiness", {})) + longform_1000_interactive_summary = dict(summary.get("longform_1000_interactive_summary", {})) + longform_1000_interactive_signoff = dict(summary.get("longform_1000_interactive_signoff", {})) + longform_1000_human_review_closeout = dict(summary.get("longform_1000_human_review_closeout", {})) + longform_1000_feasibility = dict(summary.get("longform_1000_feasibility", {})) + character_fidelity_remediation_framework = build_character_fidelity_remediation_framework(summary) + review_sample_coverage_250 = dict(summary.get("review_sample_coverage_250", {})) + review_sample_coverage_500 = dict(summary.get("review_sample_coverage_500", {})) + review_sample_coverage_1000 = dict(summary.get("review_sample_coverage_1000", {})) strongest_packs = list(summary.get("strongest_packs", [])) long_route_summary = dict(summary.get("long_route_summary", {})) + interactive_long_route_summary = dict(summary.get("interactive_long_route_summary", {})) + content_quality_contract_summary = dict(summary.get("content_quality_contract_summary", {})) + generation_hard_constraint_summary = dict(summary.get("generation_hard_constraint_summary", {})) + longform_summary = dict(summary.get("longform_summary", {})) + longform_gate = dict(summary.get("longform_gate", {})) delta_summary = dict(summary.get("delta_summary", {})) + phase_a_quality_gate = dict(summary.get("phase_a_quality_gate") or {}) + commercial_long_route_gate = dict(summary.get("commercial_long_route_gate") or {}) + benchmark_runtime_profile = dict(summary.get("benchmark_runtime_profile") or {}) ranking_changes = dict(delta_summary.get("ranking_changes", {})) current_strongest = list(ranking_changes.get("current_strongest", [])) or [ item.get("world_id", "-") for item in strongest_packs @@ -874,61 +2420,503 @@ def render_benchmark_markdown(summary: Dict[str, Any]) -> str: "- packs covered: %s" % len(summary.get("worlds", [])), "- regressions: %s" % len(delta_summary.get("regressions", [])), "", - "## Strongest Packs", + "## Benchmark Runtime Profile", + "- profile: %s" % (benchmark_runtime_profile.get("acceptance_profile", summary.get("acceptance_profile", "full")) or "full"), + "- total wall ms: %.3f" % float(benchmark_runtime_profile.get("total_wall_ms", 0.0) or 0.0), + "- slowest worlds: %s" + % ( + ", ".join( + "%s %.3fms" % ( + item.get("world_id", "-"), + float(item.get("world_total_ms", 0.0) or 0.0), + ) + for item in list(benchmark_runtime_profile.get("slowest_worlds") or []) + ) + or "-" + ), + "- stage totals: %s" + % ( + ", ".join( + "%s=%.3fms" % (key, float(value or 0.0)) + for key, value in dict(benchmark_runtime_profile.get("stage_totals_ms") or {}).items() + if key in {"simulation", "generation_runtime", "quality_pass", "lint", "evaluation", "world_total"} + ) + or "-" + ), + "- quality-pass stage actions: %s" + % ( + ", ".join( + "%s=%s" % (key, value) + for key, value in dict(benchmark_runtime_profile.get("quality_pass_stage_action_counts") or {}).items() + ) + or "-" + ), + "- fast gate: %s" + % ( + "selected %s / nightly required %s" + % ( + ", ".join(dict(benchmark_runtime_profile.get("fast_gate") or {}).get("selected_world_ids", [])) or "-", + "yes" if dict(benchmark_runtime_profile.get("fast_gate") or {}).get("nightly_full_gate_required") else "no", + ) + ), + "", + "## Phase A Quality Gate", + "- status: %s" % ("pass" if phase_a_quality_gate.get("ok") else "blocked"), + "- config version: %s" % (phase_a_quality_gate.get("config_version", "-") or "-"), + "- failed checks: %s" % (", ".join(phase_a_quality_gate.get("failed_checks", [])) or "none"), + "- weakest packs evaluated: %s" % (", ".join(phase_a_quality_gate.get("evaluated_weakest_world_ids", [])) or "-"), + "", + "## Commercial Long-Route 50 Gate", + "- applicable: %s" % ("yes" if commercial_long_route_gate.get("applicable") else "no"), + "- status: %s" % ("pass" if commercial_long_route_gate.get("ok", True) else "blocked"), + "- failed checks: %s" % (", ".join(commercial_long_route_gate.get("failed_checks", [])) or "none"), + "- evidence command: python -m src.narrativeos.benchmark.runner --worldpack all --database-url sqlite:///artifacts/commercial_long_route_50.db --benchmark-mode long_route --max-chapters 50 --markdown-out artifacts/commercial_long_route_50.md", + "", + "### Commercial Weakest-Pack Evidence", ] + commercial_weakest = weakest_packs[:3] + if commercial_weakest: + for item in commercial_weakest: + focus_mix = [ + issue + for issue in list(item.get("issue_mix") or []) + if str(issue.get("issue_code") or "") in {"Q03", "Q04", "Q05", "Q09"} + ] + lines.extend( + [ + "- %s: long-route %.3f · mid-arc drop %.3f · completion %.3f · stop %s" % ( + item.get("world_id", "-"), + float(item.get("long_route_quality", 0.0) or 0.0), + float(item.get("mid_arc_drop", 0.0) or 0.0), + float(item.get("completion_ratio", 0.0) or 0.0), + item.get("stop_reason", "-") or "-", + ), + " focus issues: %s" + % ( + ", ".join( + "%s x%s (%.3f)" + % ( + issue.get("issue_code", "-"), + int(issue.get("count", 0) or 0), + float(issue.get("share", 0.0) or 0.0), + ) + for issue in focus_mix + ) + or "clean" + ), + ] + ) + else: + lines.append("- none") + lines.extend( + [ + "", + "## Strongest Packs", + ] + ) if strongest_packs: for item in strongest_packs: lines.extend( [ - "- %s: pass %.3f · long-route %.3f · mid-arc drop %.3f · dialogue distinctness %.3f · diagnostic %.3f" % ( - item.get("world_id", "-"), - float(item.get("pass_rate", 0.0)), - float(item.get("long_route_quality", 0.0)), - float(item.get("mid_arc_drop", 0.0)), - float(item.get("dialogue_distinctness", 0.0)), - float(item.get("diagnostic_score", 0.0)), + "- %s: pass %.3f · long-route %.3f · mid-arc drop %.3f · dialogue distinctness %.3f · diagnostic %.3f" % ( + item.get("world_id", "-"), + float(item.get("pass_rate", 0.0)), + float(item.get("long_route_quality", 0.0)), + float(item.get("mid_arc_drop", 0.0)), + float(item.get("dialogue_distinctness", 0.0)), + float(item.get("diagnostic_score", 0.0)), + ), + " issue mix: %s" + % ( + ", ".join( + "%s x%s (%.3f)" + % ( + issue.get("issue_code", "-"), + int(issue.get("count", 0)), + float(issue.get("share", 0.0)), + ) + for issue in item.get("issue_mix", []) + ) + or "clean" + ), + ] + ) + else: + lines.append("- none") + if long_route_summary: + calibration = dict(long_route_summary.get("q03_q09_calibration") or {}) + lines.extend( + [ + "", + "## Long-Route Summary", + "- target chapters: %s" % long_route_summary.get("target_chapters", 0), + "- avg completion ratio: %.3f" % float(long_route_summary.get("avg_completion_ratio", 0.0)), + "- avg mid-arc drop: %.3f" % float(long_route_summary.get("avg_mid_arc_drop", 0.0)), + "- avg repetition score: %.3f" % float(long_route_summary.get("avg_repetition_score", 0.0)), + "- avg exposition ratio: %.3f" % float(long_route_summary.get("avg_exposition_ratio", 0.0)), + "- packs reaching target: %s" + % (", ".join(long_route_summary.get("packs_reaching_target", [])) or "-"), + "- premature ending packs: %s" + % (", ".join(long_route_summary.get("premature_ending_packs", [])) or "-"), + "- stop reasons: %s" + % ( + ", ".join( + "%s=%s" % (key, value) + for key, value in sorted(long_route_summary.get("stop_reason_counts", {}).items()) + ) + or "-" + ), + ] + ) + if calibration: + lines.extend( + [ + "- q03 calibration recommendations: %s" + % ( + ", ".join( + "%s=%s" % (key, value) + for key, value in sorted(dict(calibration.get("q03_recommendation_counts") or {}).items()) + ) + or "-" + ), + "- q09 calibration recommendations: %s" + % ( + ", ".join( + "%s=%s" % (key, value) + for key, value in sorted(dict(calibration.get("q09_recommendation_counts") or {}).items()) + ) + or "-" + ), + "- avg q03 primary correlation: %.3f" % float(calibration.get("avg_q03_primary_correlation", 0.0)), + "- avg q09 primary correlation: %.3f" % float(calibration.get("avg_q09_primary_correlation", 0.0)), + "- calibration coverage insufficient worlds: %s" + % (", ".join(calibration.get("coverage_insufficient_worlds", [])) or "-"), + ] + ) + if interactive_long_route_summary: + lines.extend( + [ + "", + "## Interactive Long-Route Summary", + "- profile: %s" % (interactive_long_route_summary.get("interactive_profile", summary.get("interactive_profile", "-")) or "-"), + "- target chapters: %s" % interactive_long_route_summary.get("target_chapters", summary.get("chapter_budget", 0)), + "- scenario count: %s" % interactive_long_route_summary.get("scenario_count", 0), + "- steering recovery rate: %.3f" % float(interactive_long_route_summary.get("steering_recovery_rate", 0.0)), + "- post-steer route survival: %.3f" % float(interactive_long_route_summary.get("post_steer_route_survival", 0.0)), + "- memory consistency after steer: %.3f" % float(interactive_long_route_summary.get("memory_consistency_after_steer", 0.0)), + "- promise reconciliation after steer: %.3f" % float(interactive_long_route_summary.get("promise_reconciliation_after_steer", 0.0)), + "- replan stability score: %.3f" % float(interactive_long_route_summary.get("replan_stability_score", 0.0)), + "- avg short-window issue rates: %s" + % ( + ", ".join( + "%s=%.3f" % (issue_code, float(rate)) + for issue_code, rate in dict(interactive_long_route_summary.get("avg_short_window_issue_rates") or {}).items() + ) + or "-" + ), + "- avg long-window issue rates: %s" + % ( + ", ".join( + "%s=%.3f" % (issue_code, float(rate)) + for issue_code, rate in dict(interactive_long_route_summary.get("avg_long_window_issue_rates") or {}).items() + ) + or "-" + ), + "- worlds with interactive data: %s" + % (", ".join(interactive_long_route_summary.get("worlds_with_interactive_data", [])) or "-"), + ] + ) + lines.extend(["", "## Post-Steer Issue Windows"]) + post_steer_worlds = [ + item for item in summary.get("worlds", []) + if item.get("post_steer_issue_window_summary") + ] + if not post_steer_worlds: + lines.append("- none") + for item in post_steer_worlds: + lines.append("- %s" % (item.get("world_id", "-") or "-")) + for scenario in item.get("post_steer_issue_window_summary", [])[:5]: + short_window = dict(scenario.get("short_window") or {}) + long_window = dict(scenario.get("long_window") or {}) + short_rates = ", ".join( + "%s=%.3f" % (issue_code, float(rate)) + for issue_code, rate in dict(short_window.get("issue_rates") or {}).items() + ) or "-" + long_rates = ", ".join( + "%s=%.3f" % (issue_code, float(rate)) + for issue_code, rate in dict(long_window.get("issue_rates") or {}).items() + ) or "-" + lines.append( + " chapter %s %s · short[%s] · long[%s]" + % ( + scenario.get("chapter_index", 0), + scenario.get("scenario_kind", "-"), + short_rates, + long_rates, + ) + ) + if content_quality_contract_summary: + lines.extend( + [ + "", + "## Content Quality Contract Summary", + "- band: %s" % (content_quality_contract_summary.get("band", "-") or "-"), + "- config version: %s" % (content_quality_contract_summary.get("config_version", "-") or "-"), + "- gate enforced: %s" % ("yes" if content_quality_contract_summary.get("gate_enforced") else "no"), + "- diagnostic enabled: %s" % ("yes" if content_quality_contract_summary.get("diagnostic_enabled") else "no"), + "- applicable worlds: %s" % content_quality_contract_summary.get("applicable_world_count", 0), + "- avg early-window Q03/Q04 share: %.3f" % float(content_quality_contract_summary.get("avg_early_window_q03_q04_share", 0.0)), + "- avg mid-window repeat breach rate: %.3f" % float(content_quality_contract_summary.get("avg_mid_window_repeat_breach_rate", 0.0)), + "- avg mid-window exposition breach rate: %.3f" % float(content_quality_contract_summary.get("avg_mid_window_exposition_breach_rate", 0.0)), + "- avg mid-window detail breach rate: %.3f" % float(content_quality_contract_summary.get("avg_mid_window_detail_breach_rate", 0.0)), + "- avg late-window Q09 breach rate: %.3f" % float(content_quality_contract_summary.get("avg_late_window_q09_breach_rate", 0.0)), + "- avg late-window detail breach rate: %.3f" % float(content_quality_contract_summary.get("avg_late_window_detail_breach_rate", 0.0)), + ] + ) + if generation_hard_constraint_summary: + violation_lines = [ + "- `%s`: %s (share %.3f)" + % (item.get("rule_id", "-"), int(item.get("count", 0) or 0), float(item.get("share", 0.0) or 0.0)) + for item in list(generation_hard_constraint_summary.get("violation_mix") or [])[:8] + ] or ["- none"] + scene_card_audit = dict(generation_hard_constraint_summary.get("scene_card_visible_text_audit") or {}) + scene_card_lines = [ + "- `%s`: %s" % (item.get("rule_id", "-"), int(item.get("count", 0) or 0)) + for item in list(scene_card_audit.get("failed_rule_mix") or [])[:6] + ] or ["- none"] + lines.extend( + [ + "", + "## Generation Hard Constraint Summary", + "- chapters: %s" % generation_hard_constraint_summary.get("chapter_count", 0), + "- hard fail count: %s" % generation_hard_constraint_summary.get("hard_fail_count", 0), + "- hard fail rate: %.3f" % float(generation_hard_constraint_summary.get("hard_fail_rate", 0.0)), + "- repair attempts: %s" % generation_hard_constraint_summary.get("repair_attempt_count", 0), + "- repair success rate: %.3f" % float(generation_hard_constraint_summary.get("repair_success_rate", 0.0)), + "- scene-card visible text violations: %s" % int(scene_card_audit.get("violation_count", 0) or 0), + "", + "### Hard Constraint Violation Mix", + *violation_lines, + "", + "### Scene-Card Visible Text Audit", + *scene_card_lines, + ] + ) + if longform_gate: + calibration = dict(longform_gate.get("calibration") or {}) + observed = dict(calibration.get("observed_metrics") or {}) + recommended_thresholds = dict(calibration.get("recommended_thresholds") or {}) + lines.extend( + [ + "", + "## Longform 100 Gate", + "- pass rate: %.3f" % float(longform_gate.get("pass_rate", 0.0)), + "- failed worlds: %s" % (", ".join(longform_gate.get("failed_worlds", [])) or "-"), + "- avg q09 incidence: %.3f" % float(longform_summary.get("q09_incidence_rate", 0.0)), + "- avg promise unresolved: %.3f" % float(longform_summary.get("promise_unresolved_rate", 0.0)), + "- avg arc task repeat: %.3f" % float(longform_summary.get("arc_task_repeat_rate", 0.0)), + ] + ) + if observed: + lines.extend( + [ + " observed completion ratio p75/max: %.3f / %.3f" + % ( + float(dict(observed.get("completion_ratio") or {}).get("p75", 0.0)), + float(dict(observed.get("completion_ratio") or {}).get("max", 0.0)), ), - " issue mix: %s" + " observed q09 incidence p75/max: %.3f / %.3f" % ( - ", ".join( - "%s x%s (%.3f)" - % ( - issue.get("issue_code", "-"), - int(issue.get("count", 0)), - float(issue.get("share", 0.0)), - ) - for issue in item.get("issue_mix", []) - ) - or "clean" + float(dict(observed.get("q09_incidence_rate") or {}).get("p75", 0.0)), + float(dict(observed.get("q09_incidence_rate") or {}).get("max", 0.0)), + ), + " observed arc repeat p75/max: %.3f / %.3f" + % ( + float(dict(observed.get("arc_task_repeat_rate") or {}).get("p75", 0.0)), + float(dict(observed.get("arc_task_repeat_rate") or {}).get("max", 0.0)), ), ] ) - else: - lines.append("- none") - if long_route_summary: + if longform_250_summary: lines.extend( [ "", - "## Long-Route Summary", - "- target chapters: %s" % long_route_summary.get("target_chapters", 0), - "- avg completion ratio: %.3f" % float(long_route_summary.get("avg_completion_ratio", 0.0)), - "- avg mid-arc drop: %.3f" % float(long_route_summary.get("avg_mid_arc_drop", 0.0)), - "- avg repetition score: %.3f" % float(long_route_summary.get("avg_repetition_score", 0.0)), - "- avg exposition ratio: %.3f" % float(long_route_summary.get("avg_exposition_ratio", 0.0)), - "- packs reaching target: %s" - % (", ".join(long_route_summary.get("packs_reaching_target", [])) or "-"), - "- premature ending packs: %s" - % (", ".join(long_route_summary.get("premature_ending_packs", [])) or "-"), - "- stop reasons: %s" + "## Longform 250 Evidence", + "- gate pass rate: %.3f" % float(longform_250_summary.get("gate_pass_rate", 0.0)), + "- volume boundary survival: %.3f" % float(longform_250_summary.get("volume_boundary_survival", 0.0)), + "- memory recall coverage: %.3f" % float(longform_250_summary.get("memory_recall_coverage", 0.0)), + "- replan stability score: %.3f" % float(longform_250_summary.get("replan_stability_score", 0.0)), + "- volume snapshot integrity: %.3f" % float(longform_250_summary.get("volume_snapshot_integrity", 0.0)), + "- mid-volume pass: %.3f" % float(longform_250_summary.get("mid_volume_pass_rate", 0.0)), + "- late-volume pass: %.3f" % float(longform_250_summary.get("late_volume_pass_rate", 0.0)), + "- failed worlds: %s" % (", ".join(longform_250_summary.get("failed_worlds", [])) or "-"), + ] + ) + if longform_250_interactive_summary: + lines.extend( + [ + "", + "## Longform 250 Interactive Gate", + "- gate pass rate: %.3f" % float(longform_250_interactive_summary.get("gate_pass_rate", 0.0)), + "- steering recovery rate: %.3f" % float(longform_250_interactive_summary.get("steering_recovery_rate", 0.0)), + "- post-steer route survival: %.3f" % float(longform_250_interactive_summary.get("post_steer_route_survival", 0.0)), + "- memory consistency after steer: %.3f" % float(longform_250_interactive_summary.get("memory_consistency_after_steer", 0.0)), + "- promise reconciliation after steer: %.3f" % float(longform_250_interactive_summary.get("promise_reconciliation_after_steer", 0.0)), + "- replan stability score: %.3f" % float(longform_250_interactive_summary.get("replan_stability_score", 0.0)), + "- failed worlds: %s" % (", ".join(longform_250_interactive_summary.get("failed_worlds", [])) or "-"), + ] + ) + if longform_500_summary: + lines.extend( + [ + "", + "## Longform 500 Evidence", + "- gate pass rate: %.3f" % float(longform_500_summary.get("gate_pass_rate", 0.0)), + "- series boundary survival: %.3f" % float(longform_500_summary.get("series_boundary_survival", 0.0)), + "- series memory snapshot integrity: %.3f" % float(longform_500_summary.get("series_memory_snapshot_integrity", 0.0)), + "- memory recall coverage: %.3f" % float(longform_500_summary.get("memory_recall_coverage", 0.0)), + "- replan stability score: %.3f" % float(longform_500_summary.get("replan_stability_score", 0.0)), + "- late-series pass: %.3f" % float(longform_500_summary.get("late_series_pass_rate", 0.0)), + "- series ending control score: %.3f" % float(longform_500_summary.get("series_ending_control_score", 0.0)), + "- failed worlds: %s" % (", ".join(longform_500_summary.get("failed_worlds", [])) or "-"), + ] + ) + if longform_500_interactive_summary: + lines.extend( + [ + "", + "## Longform 500 Interactive Gate", + "- gate pass rate: %.3f" % float(longform_500_interactive_summary.get("gate_pass_rate", 0.0)), + "- steering recovery rate: %.3f" % float(longform_500_interactive_summary.get("steering_recovery_rate", 0.0)), + "- post-steer route survival: %.3f" % float(longform_500_interactive_summary.get("post_steer_route_survival", 0.0)), + "- memory consistency after steer: %.3f" % float(longform_500_interactive_summary.get("memory_consistency_after_steer", 0.0)), + "- promise reconciliation after steer: %.3f" % float(longform_500_interactive_summary.get("promise_reconciliation_after_steer", 0.0)), + "- replan stability score: %.3f" % float(longform_500_interactive_summary.get("replan_stability_score", 0.0)), + "- failed worlds: %s" % (", ".join(longform_500_interactive_summary.get("failed_worlds", [])) or "-"), + ] + ) + if longform_1000_summary: + lines.extend( + [ + "", + "## Longform 1000 Diagnostics", + "- diagnostic pass rate: %.3f" % float(longform_1000_summary.get("diagnostic_pass_rate", 0.0)), + "- series boundary survival: %.3f" % float(longform_1000_summary.get("series_boundary_survival", 0.0)), + "- series memory snapshot integrity: %.3f" % float(longform_1000_summary.get("series_memory_snapshot_integrity", 0.0)), + "- series snapshot count / target: %.3f / %.3f" + % ( + float(longform_1000_summary.get("series_snapshot_count", 0.0)), + float(longform_1000_summary.get("retained_series_snapshot_target", 0.0)), + ), + "- archive retention integrity: %.3f" % float(longform_1000_summary.get("archive_retention_integrity", 0.0)), + "- timeline retention integrity: %.3f" % float(longform_1000_summary.get("timeline_retention_integrity", 0.0)), + "- continuation-state retention integrity: %.3f" % float(longform_1000_summary.get("continuation_state_retention_integrity", 0.0)), + "- late-stage runtime p95 ms: %.3f" % float(longform_1000_summary.get("late_stage_runtime_p95_ms", 0.0)), + "- late-stage runtime budget score: %.3f" % float(longform_1000_summary.get("late_stage_runtime_budget_score", 0.0)), + "- series ending control score: %.3f" % float(longform_1000_summary.get("series_ending_control_score", 0.0)), + "- failed worlds: %s" % (", ".join(longform_1000_summary.get("failed_worlds", [])) or "-"), + ] + ) + if character_fidelity_remediation_framework.get("available"): + lines.extend( + [ + "", + "## Q06 Character Fidelity Framework", + "- q06 worlds: %s" % int(character_fidelity_remediation_framework.get("q06_world_count", 0) or 0), + "- top worlds: %s" % ( ", ".join( - "%s=%s" % (key, value) - for key, value in sorted(long_route_summary.get("stop_reason_counts", {}).items()) + "%s(share=%.3f,fidelity=%.3f)" + % ( + item.get("world_id", "-"), + float(item.get("q06_issue_share", 0.0)), + float(item.get("character_fidelity", 0.0)), + ) + for item in character_fidelity_remediation_framework.get("top_worlds", []) + ) + or "-" + ), + "- top characters: %s" + % ( + ", ".join( + "%s x%s" + % ( + item.get("character_id", "-"), + int(item.get("count", 0)), + ) + for item in character_fidelity_remediation_framework.get("top_characters", []) + ) + or "-" + ), + "- top duties: %s" + % ( + ", ".join( + "%s x%s" + % ( + item.get("duty_type", "-"), + int(item.get("count", 0)), + ) + for item in character_fidelity_remediation_framework.get("top_duties", []) + ) + or "-" + ), + "- recommended assets: %s" % (", ".join(character_fidelity_remediation_framework.get("recommended_assets", [])) or "-"), + ] + ) + if review_sample_coverage_250: + lines.extend( + [ + "", + "## Longform 250 Review Sampling", + "- closeout status: %s" % (review_sample_coverage_250.get("closeout_status", "-") or "-"), + "- closeout ready: %s" % ("yes" if review_sample_coverage_250.get("closeout_ready") else "no"), + "- reviewed worlds: %s" % int(review_sample_coverage_250.get("reviewed_world_count", 0) or 0), + "- human-reviewed worlds: %s" % int(review_sample_coverage_250.get("human_reviewed_world_count", 0) or 0), + "- auto-seeded worlds: %s" % int(review_sample_coverage_250.get("auto_seeded_world_count", 0) or 0), + "- human closeout status: %s" % (review_sample_coverage_250.get("human_closeout_status", "-") or "-"), + "- human closeout ready: %s" % ("yes" if review_sample_coverage_250.get("human_closeout_ready") else "no"), + "- executed targets: %s/%s" + % ( + int(review_sample_coverage_250.get("executed_target_count", 0) or 0), + int(review_sample_coverage_250.get("planned_target_count", 0) or 0), + ), + "- unreviewed targets: %s" % len(review_sample_coverage_250.get("unreviewed_targets", [])), + "- human-unreviewed targets: %s" % len(review_sample_coverage_250.get("human_unreviewed_targets", [])), + "- window coverage: %s" + % ( + " / ".join( + "%s=%s/%s (human %s · auto %s)" + % ( + label, + int(dict(payload).get("reviewed_count", 0)), + int(dict(payload).get("target_count", 0)), + int(dict(payload).get("human_reviewed_count", 0)), + int(dict(payload).get("auto_seeded_count", 0)), + ) + for label, payload in dict(review_sample_coverage_250.get("window_coverage", {})).items() ) or "-" ), ] ) + if review_sample_coverage_500: + lines.extend( + [ + "", + "## Longform 500 Review Sampling", + "- closeout status: %s" % (review_sample_coverage_500.get("closeout_status", "-") or "-"), + "- closeout ready: %s" % ("yes" if review_sample_coverage_500.get("closeout_ready") else "no"), + "- human closeout status: %s" % (review_sample_coverage_500.get("human_closeout_status", "-") or "-"), + "- human closeout ready: %s" % ("yes" if review_sample_coverage_500.get("human_closeout_ready") else "no"), + "- ending window: %s" % (review_sample_coverage_500.get("ending_window_label", "-") or "-"), + "- ending window human reviewed: %s/%s" + % ( + int(review_sample_coverage_500.get("ending_window_human_reviewed_count", 0) or 0), + int(review_sample_coverage_500.get("ending_window_target_count", 0) or 0), + ), + "- human-unreviewed targets: %s" % len(review_sample_coverage_500.get("human_unreviewed_targets", [])), + ] + ) lines.extend(["", "## Weakest Packs"]) if weakest_packs: for item in weakest_packs: @@ -1046,8 +3034,376 @@ def render_benchmark_markdown(summary: Dict[str, Any]) -> str: ) ) ) + window_breaches = list(item.get("window_breach_attribution", [])) + if window_breaches: + lines.append( + " window breaches: %s" + % ( + " | ".join( + "%s:%s %.3f>%.3f" + % ( + breach.get("window_label", "-"), + "/".join(breach.get("issue_codes", [])) or "-", + float(breach.get("actual", 0.0)), + float(breach.get("threshold", 0.0)), + ) + for breach in window_breaches[:3] + ) + ) + ) + stop_condition = dict(item.get("stop_condition", {})) + if stop_condition: + lines.append( + " stop condition: %s" + % ( + "%s (%s)" + % ( + stop_condition.get("status", "-"), + ", ".join(stop_condition.get("failed_checks", [])) or "all_checks_passed", + ) + ) + ) else: lines.append("- none") + if weakest_pack_polish_program: + lines.extend(["", "## Weakest Pack Polish Program"]) + lines.extend( + [ + "- program status: %s" % (weakest_pack_polish_program.get("status", "-") or "-"), + "- stop-ready worlds: %s" % (", ".join(weakest_pack_polish_program.get("stop_ready_worlds", [])) or "-"), + "- continue worlds: %s" % (", ".join(weakest_pack_polish_program.get("continue_worlds", [])) or "-"), + "- recommended action: %s" % (weakest_pack_polish_program.get("recommended_action", "-") or "-"), + ] + ) + for bundle in weakest_pack_polish_program.get("bundles", [])[:3]: + lines.append( + "- %s · %s · dimensions %s" + % ( + bundle.get("world_id", "-"), + bundle.get("bundle_status", "-"), + ", ".join(bundle.get("target_dimensions", [])) or "-", + ) + ) + if bundle.get("bundle_items"): + lines.append( + " bundle: %s" + % ( + " | ".join( + "%s x %s x %s" + % ( + item.get("module", "-"), + item.get("asset", "-"), + item.get("policy", "-"), + ) + for item in bundle.get("bundle_items", [])[:2] + ) + ) + ) + if strategy_bundle_batch_validation: + validation_available = bool(strategy_bundle_batch_validation.get("available")) + skipped_worlds = list(strategy_bundle_batch_validation.get("skipped_worlds", [])) + lines.extend(["", "## Strategy Bundle Batch Validation"]) + lines.extend( + [ + "- status: %s" + % ( + "ready" if validation_available else "not_run" + ), + "- strategy bundle: %s (%s)" + % ( + strategy_bundle_batch_validation.get("strategy_bundle_label", "-") or "-", + strategy_bundle_batch_validation.get("strategy_bundle_id", "-") or "-", + ), + "- execution mode: %s" + % (strategy_bundle_batch_validation.get("batch_execution_mode", "-") or "-"), + "- weakest source worlds: %s" + % (", ".join(strategy_bundle_batch_validation.get("weakest_source_world_ids", [])) or "-"), + "- compatible worlds: %s" + % (", ".join(strategy_bundle_batch_validation.get("compatible_world_ids", [])) or "-"), + "- validated world count: %s" + % int(strategy_bundle_batch_validation.get("validated_world_count", 0) or 0), + "- effectiveness rate: %.3f" + % float(strategy_bundle_batch_validation.get("effectiveness_rate", 0.0) or 0.0), + "- decision: %s" % (strategy_bundle_batch_validation.get("decision", "-") or "-"), + "- decision reason: %s" + % (strategy_bundle_batch_validation.get("decision_reason", "-") or "-"), + "- overall status counts: %s" + % ( + ", ".join( + "%s=%s" % (key, value) + for key, value in sorted( + dict( + dict( + strategy_bundle_batch_validation.get("aggregated_result_attribution", {}) + ).get("overall_status_counts", {}) + ).items() + ) + ) + or "-" + ), + "- stop decision counts: %s" + % ( + ", ".join( + "%s=%s" % (key, value) + for key, value in sorted( + dict( + dict( + strategy_bundle_batch_validation.get("aggregated_result_attribution", {}) + ).get("stop_decision_counts", {}) + ).items() + ) + ) + or "-" + ), + "- skipped worlds: %s" + % ( + " | ".join( + "%s(%s)" + % ( + item.get("world_id", "-"), + item.get("reason", "-"), + ) + for item in skipped_worlds[:5] + ) + or "-" + ), + "- adaptation targets: %s" + % ( + " | ".join( + "%s:%s=%s" + % ( + item.get("kind", "-"), + item.get("name", "-"), + item.get("count", 0), + ) + for item in strategy_bundle_batch_validation.get("adaptation_targets", [])[:5] + ) + or "-" + ), + ] + ) + if strategy_bundle_batch_validation_history or strategy_bundle_batch_validation_trend: + history_entries = list(strategy_bundle_batch_validation_history.get("entries", []) or []) + first_history_entry = history_entries[0] if history_entries else {} + lines.extend(["", "## Strategy Bundle Batch Validation History"]) + lines.extend( + [ + "- strategy bundle: %s (%s)" + % ( + first_history_entry.get("strategy_bundle_label", "") + or strategy_bundle_batch_validation.get("strategy_bundle_label", "-") + or strategy_bundle_batch_validation_trend.get("strategy_bundle_id", "-") + or "-", + strategy_bundle_batch_validation_trend.get("strategy_bundle_id", "-") or "-", + ), + "- trend status: %s" % (strategy_bundle_batch_validation_trend.get("trend_status", "-") or "-"), + "- trend reason: %s" % (strategy_bundle_batch_validation_trend.get("trend_reason", "-") or "-"), + "- recent run count: %s" % int(strategy_bundle_batch_validation_trend.get("recent_run_count", 0) or 0), + "- latest decision: %s" % (strategy_bundle_batch_validation_trend.get("latest_decision", "-") or "-"), + "- latest effectiveness rate: %.3f" % float(strategy_bundle_batch_validation_trend.get("latest_effectiveness_rate", 0.0) or 0.0), + "- delta effectiveness rate: %+.3f" % float(strategy_bundle_batch_validation_trend.get("delta_effectiveness_rate", 0.0) or 0.0), + "- retire recommended: %s" % ("yes" if strategy_bundle_batch_validation_trend.get("retire_recommended") else "no"), + "- recent runs: %s" + % ( + " | ".join( + "%s %s eff=%.3f worlds=%s" + % ( + item.get("generated_at", "-"), + item.get("decision", "-") or "-", + float(item.get("effectiveness_rate", 0.0) or 0.0), + int(item.get("validated_world_count", 0) or 0), + ) + for item in history_entries[:5] + ) + or "-" + ), + ] + ) + if longform_l1_signoff: + lines.extend(["", "## Longform L1 Sign-off"]) + lines.extend( + [ + "- status: %s" % (longform_l1_signoff.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_l1_signoff.get("ready") else "no"), + "- reason: %s" % (longform_l1_signoff.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_l1_signoff.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_l1_signoff.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_l1_signoff.get("required_evidence", [])) or "-"), + ] + ) + if interactive_longform_signoff: + lines.extend(["", "## Interactive Longform Sign-off"]) + lines.extend( + [ + "- status: %s" % (interactive_longform_signoff.get("status", "-") or "-"), + "- ready: %s" % ("yes" if interactive_longform_signoff.get("ready") else "no"), + "- reason: %s" % (interactive_longform_signoff.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(interactive_longform_signoff.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(interactive_longform_signoff.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(interactive_longform_signoff.get("required_evidence", [])) or "-"), + ] + ) + if longform_250_signoff: + lines.extend(["", "## Longform 250 Sign-off"]) + lines.extend( + [ + "- status: %s" % (longform_250_signoff.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_250_signoff.get("ready") else "no"), + "- reason: %s" % (longform_250_signoff.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_250_signoff.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_250_signoff.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_250_signoff.get("required_evidence", [])) or "-"), + ] + ) + if longform_250_interactive_signoff: + lines.extend(["", "## Longform 250 Interactive Sign-off"]) + lines.extend( + [ + "- status: %s" % (longform_250_interactive_signoff.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_250_interactive_signoff.get("ready") else "no"), + "- reason: %s" % (longform_250_interactive_signoff.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_250_interactive_signoff.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_250_interactive_signoff.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_250_interactive_signoff.get("required_evidence", [])) or "-"), + ] + ) + if longform_250_human_review_closeout: + lines.extend(["", "## Longform 250 Human Review Closeout"]) + lines.extend( + [ + "- status: %s" % (longform_250_human_review_closeout.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_250_human_review_closeout.get("ready") else "no"), + "- reason: %s" % (longform_250_human_review_closeout.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_250_human_review_closeout.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_250_human_review_closeout.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_250_human_review_closeout.get("required_evidence", [])) or "-"), + ] + ) + if longform_500_signoff: + lines.extend(["", "## Longform 500 Sign-off"]) + lines.extend( + [ + "- status: %s" % (longform_500_signoff.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_500_signoff.get("ready") else "no"), + "- reason: %s" % (longform_500_signoff.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_500_signoff.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_500_signoff.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_500_signoff.get("required_evidence", [])) or "-"), + ] + ) + if longform_500_human_review_closeout: + lines.extend(["", "## Longform 500 Human Review Closeout"]) + lines.extend( + [ + "- status: %s" % (longform_500_human_review_closeout.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_500_human_review_closeout.get("ready") else "no"), + "- reason: %s" % (longform_500_human_review_closeout.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_500_human_review_closeout.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_500_human_review_closeout.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_500_human_review_closeout.get("required_evidence", [])) or "-"), + ] + ) + if longform_500_ending_signoff: + lines.extend(["", "## Longform 500 Ending Sign-off"]) + lines.extend( + [ + "- status: %s" % (longform_500_ending_signoff.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_500_ending_signoff.get("ready") else "no"), + "- reason: %s" % (longform_500_ending_signoff.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_500_ending_signoff.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_500_ending_signoff.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_500_ending_signoff.get("required_evidence", [])) or "-"), + ] + ) + if longform_500_interactive_signoff: + lines.extend(["", "## Longform 500 Interactive Sign-off"]) + lines.extend( + [ + "- status: %s" % (longform_500_interactive_signoff.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_500_interactive_signoff.get("ready") else "no"), + "- reason: %s" % (longform_500_interactive_signoff.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_500_interactive_signoff.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_500_interactive_signoff.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_500_interactive_signoff.get("required_evidence", [])) or "-"), + ] + ) + if longform_1000_summary: + lines.extend(["", "## Longform 1000 Evidence"]) + lines.extend( + [ + "- diagnostic pass rate: %.3f" % float(longform_1000_summary.get("diagnostic_pass_rate", 0.0)), + "- series boundary survival: %.3f" % float(longform_1000_summary.get("series_boundary_survival", 0.0)), + "- series memory snapshot integrity: %.3f" % float(longform_1000_summary.get("series_memory_snapshot_integrity", 0.0)), + "- memory recall coverage: %.3f" % float(longform_1000_summary.get("memory_recall_coverage", 0.0)), + "- replan stability score: %.3f" % float(longform_1000_summary.get("replan_stability_score", 0.0)), + "- late stage runtime p95 ms: %.3f" % float(longform_1000_summary.get("late_stage_runtime_p95_ms", 0.0)), + "- late stage runtime budget score: %.3f" % float(longform_1000_summary.get("late_stage_runtime_budget_score", 0.0)), + "- failed worlds: %s" % (", ".join(longform_1000_summary.get("failed_worlds", [])) or "-"), + ] + ) + if longform_1000_readiness: + lines.extend(["", "## Longform 1000 Readiness"]) + lines.extend( + [ + "- status: %s" % (longform_1000_readiness.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_1000_readiness.get("ready") else "no"), + "- reason: %s" % (longform_1000_readiness.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_1000_readiness.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_1000_readiness.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_1000_readiness.get("required_evidence", [])) or "-"), + ] + ) + if longform_1000_interactive_summary: + lines.extend(["", "## Longform 1000 Interactive Evidence"]) + lines.extend( + [ + "- gate pass rate: %.3f" % float(longform_1000_interactive_summary.get("gate_pass_rate", 0.0)), + "- steering recovery rate: %.3f" % float(longform_1000_interactive_summary.get("steering_recovery_rate", 0.0)), + "- post-steer route survival: %.3f" % float(longform_1000_interactive_summary.get("post_steer_route_survival", 0.0)), + "- memory consistency after steer: %.3f" % float(longform_1000_interactive_summary.get("memory_consistency_after_steer", 0.0)), + "- promise reconciliation after steer: %.3f" % float(longform_1000_interactive_summary.get("promise_reconciliation_after_steer", 0.0)), + "- replan stability score: %.3f" % float(longform_1000_interactive_summary.get("replan_stability_score", 0.0)), + "- failed worlds: %s" % (", ".join(longform_1000_interactive_summary.get("failed_worlds", [])) or "-"), + ] + ) + if longform_1000_interactive_signoff: + lines.extend(["", "## Longform 1000 Interactive Sign-off"]) + lines.extend( + [ + "- status: %s" % (longform_1000_interactive_signoff.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_1000_interactive_signoff.get("ready") else "no"), + "- reason: %s" % (longform_1000_interactive_signoff.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_1000_interactive_signoff.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_1000_interactive_signoff.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_1000_interactive_signoff.get("required_evidence", [])) or "-"), + ] + ) + if longform_1000_human_review_closeout: + lines.extend(["", "## Longform 1000 Human Review Closeout"]) + lines.extend( + [ + "- status: %s" % (longform_1000_human_review_closeout.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_1000_human_review_closeout.get("ready") else "no"), + "- reason: %s" % (longform_1000_human_review_closeout.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_1000_human_review_closeout.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_1000_human_review_closeout.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_1000_human_review_closeout.get("required_evidence", [])) or "-"), + "- human reviewed target count: %s" % int(review_sample_coverage_1000.get("human_reviewed_target_count", 0) or 0), + "- planned target count: %s" % int(review_sample_coverage_1000.get("planned_target_count", 0) or 0), + ] + ) + if longform_1000_feasibility: + lines.extend(["", "## Longform 1000 Feasibility"]) + lines.extend( + [ + "- status: %s" % (longform_1000_feasibility.get("status", "-") or "-"), + "- ready: %s" % ("yes" if longform_1000_feasibility.get("ready") else "no"), + "- reason: %s" % (longform_1000_feasibility.get("reason", "-") or "-"), + "- blocking worlds: %s" % (", ".join(longform_1000_feasibility.get("blocking_worlds", [])) or "-"), + "- watch worlds: %s" % (", ".join(longform_1000_feasibility.get("watch_worlds", [])) or "-"), + "- required evidence: %s" % (", ".join(longform_1000_feasibility.get("required_evidence", [])) or "-"), + ] + ) lines.extend( [ "", diff --git a/src/narrativeos/benchmark/runner.py b/src/narrativeos/benchmark/runner.py index f3e618e..b67a9af 100644 --- a/src/narrativeos/benchmark/runner.py +++ b/src/narrativeos/benchmark/runner.py @@ -1,21 +1,64 @@ from __future__ import annotations import argparse +import copy +import inspect import json +import sys +from datetime import datetime, timezone from pathlib import Path -from typing import Callable, Dict, Iterable, List, Sequence +from tempfile import TemporaryDirectory +from time import perf_counter +from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple +from ..content_quality_strategy_execution import ( + build_step_level_apply_summary, + build_strategy_bundle_batch_validation_summary, + build_strategy_bundle_batch_validation_trend, + execute_strategy_bundle_protocol, + list_strategy_bundle_batch_validation_history, + record_strategy_bundle_batch_validation_run, +) +from ..content_quality_contracts import ( + asset_quality_contract_coverage, + content_quality_window_metrics, + diagnostic_issue_codes_for_chapter_payload, +) +from ..longform import calibrate_longform_thresholds, evaluate_longform_gate from ..models import EvaluationReport +from ..quality.hard_constraints import ( + aggregate_generation_hard_constraint_summaries, + summarize_generation_hard_constraints, +) from ..repository import SQLAlchemyRepository +from ..services.training_signal import TrainingSignalService from ..worldpacks.registry import FileSystemWorldRegistry +from .content_quality_contract_gate import evaluate_content_quality_contract_gate from .reporting import ( assign_diagnostic_ranks, benchmark_delta_report, + build_strategy_validation_summary, + build_content_quality_contract_summary, build_dimension_scores, + build_interactive_long_route_summary, build_issue_mix, build_issue_summary, + build_interactive_longform_signoff, build_long_route_diagnostics, build_long_route_summary, + build_longform_250_human_review_closeout, + build_longform_250_signoff, + build_longform_250_interactive_signoff, + build_longform_500_ending_signoff, + build_longform_500_human_review_closeout, + build_longform_500_interactive_signoff, + build_longform_500_signoff, + build_longform_1000_human_review_closeout, + build_longform_1000_interactive_signoff, + build_longform_1000_readiness, + build_longform_1000_feasibility, + build_longform_l1_signoff, + build_weakest_pack_polish_program, build_route_diagnostics, build_weakest_pack_diagnostic, rank_strongest_packs, @@ -23,17 +66,1513 @@ rank_weakest_packs, render_benchmark_markdown, ) +from .release_quality_gate import evaluate_commercial_long_route_gate, evaluate_release_quality_gate BENCHMARK_PACKS = [item["world_id"] for item in FileSystemWorldRegistry().list_benchmark_worldpacks()] +INTERACTIVE_LONGFORM_THRESHOLDS = { + "steering_recovery_rate_min": 0.67, + "post_steer_route_survival_min": 0.55, + "memory_consistency_after_steer_min": 0.6, + "promise_reconciliation_after_steer_min": 0.55, + "replan_stability_score_min": 0.67, +} +INTERACTIVE_LONGFORM_250_THRESHOLDS = dict(INTERACTIVE_LONGFORM_THRESHOLDS) +INTERACTIVE_LONGFORM_500_THRESHOLDS = dict(INTERACTIVE_LONGFORM_THRESHOLDS) +INTERACTIVE_LONGFORM_1000_THRESHOLDS = dict(INTERACTIVE_LONGFORM_THRESHOLDS) +LONGFORM_250_REVIEW_WINDOWS = ( + ("1-20", 1, 20), + ("80-120", 80, 120), + ("200-250", 200, 250), +) +DIAGNOSTIC_SCAN_SLOW_MS = 250.0 +BENCHMARK_STAGE_SLOW_MS = 60_000.0 + + +def _elapsed_ms(started: float) -> float: + return round((perf_counter() - started) * 1000.0, 3) + + +class _BenchmarkProgressWriter: + def __init__(self, path: Optional[Path]) -> None: + self.path = path + self.started = perf_counter() + if self.path: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text("", encoding="utf-8") + + def emit(self, event: str, **fields: object) -> None: + payload = { + "schema_version": "benchmark_progress/v1", + "event": event, + "generated_at": datetime.now(timezone.utc).isoformat(), + "elapsed_ms": _elapsed_ms(self.started), + **fields, + } + if self.path: + with self.path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n") + print( + "[benchmark] %s %s" + % ( + event, + " ".join(f"{key}={value}" for key, value in fields.items() if value not in (None, "", [])), + ), + file=sys.stderr, + flush=True, + ) + + def emit_stage(self, *, world_id: str, stage: str, elapsed_ms: float, **fields: object) -> None: + severity = "warning" if elapsed_ms >= BENCHMARK_STAGE_SLOW_MS else "info" + self.emit( + "stage_complete", + world_id=world_id, + stage=stage, + stage_elapsed_ms=round(float(elapsed_ms or 0.0), 3), + severity=severity, + **fields, + ) + if severity == "warning": + self.emit( + "slow_stage", + world_id=world_id, + stage=stage, + stage_elapsed_ms=round(float(elapsed_ms or 0.0), 3), + threshold_ms=BENCHMARK_STAGE_SLOW_MS, + ) + + +def _diagnostic_issue_scan_key(payload: Dict[str, object], *, target_chapters: int) -> Tuple[object, ...]: + lint_metrics = dict((payload.get("hard_validator_results") or {}).get("lint_metrics") or {}) + repetition_bundle = dict(lint_metrics.get("repetition_signal_bundle") or {}) + issue_codes = tuple( + sorted( + str(item.get("issue_code") or "") + for item in list(payload.get("issues") or []) + if str(item.get("issue_code") or "") + ) + ) + def metric(value: object) -> float: + try: + return round(float(value or 0.0), 6) + except (TypeError, ValueError): + return 0.0 + + return ( + int(target_chapters or 0), + str(payload.get("chapter_id") or ""), + issue_codes, + metric(lint_metrics.get("repetition_score", 0.0)), + metric(lint_metrics.get("exposition_ratio", 0.0)), + metric(lint_metrics.get("concrete_detail_density", 0.0)), + metric(lint_metrics.get("dialogue_plus_action_ratio", 0.0)), + metric(repetition_bundle.get("event_coverage_gap_score", 0.0)), + metric(repetition_bundle.get("beat_coverage_gap_score", 0.0)), + metric(dict(payload.get("scores") or {}).get("hook_quality", 0.0)), + ) + + +def _diagnostic_issue_scan_payload(payload: Dict[str, object]) -> Dict[str, object]: + lint_metrics = dict((payload.get("hard_validator_results") or {}).get("lint_metrics") or {}) + repetition_bundle = dict(lint_metrics.get("repetition_signal_bundle") or {}) + return { + "chapter_id": payload.get("chapter_id"), + "issues": [ + {"issue_code": str(item.get("issue_code") or "")} + for item in list(payload.get("issues") or []) + if str(item.get("issue_code") or "") + ], + "hard_validator_results": { + "lint_metrics": { + "repetition_score": lint_metrics.get("repetition_score", 0.0), + "exposition_ratio": lint_metrics.get("exposition_ratio", 0.0), + "concrete_detail_density": lint_metrics.get("concrete_detail_density", 0.0), + "dialogue_plus_action_ratio": lint_metrics.get("dialogue_plus_action_ratio", 0.0), + "repetition_signal_bundle": { + "event_coverage_gap_score": repetition_bundle.get("event_coverage_gap_score", 0.0), + "beat_coverage_gap_score": repetition_bundle.get("beat_coverage_gap_score", 0.0), + }, + } + }, + "scores": { + "hook_quality": dict(payload.get("scores") or {}).get("hook_quality", 0.0), + "overall_score": dict(payload.get("scores") or {}).get("overall_score", 0.0), + }, + } + + +class _DiagnosticIssueScanCache: + def __init__(self) -> None: + self._entries: Dict[Tuple[object, ...], List[str]] = {} + self.hits = 0 + self.misses = 0 + self.slow_scans: List[Dict[str, object]] = [] + + def codes_for(self, payload: Dict[str, object], *, target_chapters: int) -> List[str]: + key = _diagnostic_issue_scan_key(payload, target_chapters=target_chapters) + if key in self._entries: + self.hits += 1 + return list(self._entries[key]) + self.misses += 1 + scan_payload = _diagnostic_issue_scan_payload(payload) + started = perf_counter() + issue_codes = diagnostic_issue_codes_for_chapter_payload( + scan_payload, + target_chapters=target_chapters, + ) + elapsed_ms = _elapsed_ms(started) + if elapsed_ms >= DIAGNOSTIC_SCAN_SLOW_MS: + self.slow_scans.append( + { + "chapter_id": str(payload.get("chapter_id") or ""), + "elapsed_ms": elapsed_ms, + "issue_codes": list(issue_codes), + } + ) + self._entries[key] = list(issue_codes) + return list(issue_codes) + + def summary(self) -> Dict[str, object]: + return { + "enabled": True, + "scope": "benchmark_runner_process", + "entry_count": len(self._entries), + "hits": self.hits, + "misses": self.misses, + "slow_scan_threshold_ms": DIAGNOSTIC_SCAN_SLOW_MS, + "slow_scan_count": len(self.slow_scans), + "slow_scans": list(self.slow_scans[:10]), + "payload_policy": "bounded_metrics_only", + } + + +def _write_benchmark_checkpoint( + path: Optional[Path], + *, + benchmark_mode: str, + chapter_budget: int, + worlds: Sequence[Dict[str, object]], + diagnostic_scan_cache: _DiagnosticIssueScanCache, + stage: str, +) -> None: + if not path: + return + path.parent.mkdir(parents=True, exist_ok=True) + checkpoint_worlds = [ + { + "world_id": item.get("world_id"), + "world_version_id": item.get("world_version_id"), + "route_longevity": item.get("route_longevity"), + "pass_rate": item.get("pass_rate"), + "block_rate": item.get("block_rate"), + "longform_500_gate": item.get("longform_500_gate"), + "runtime_profile": item.get("runtime_profile"), + } + for item in worlds + ] + payload = { + "schema_version": "benchmark_checkpoint/v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "stage": stage, + "benchmark_mode": benchmark_mode, + "chapter_budget": int(chapter_budget or 0), + "completed_world_count": len(checkpoint_worlds), + "completed_worlds": checkpoint_worlds, + "diagnostic_issue_scan_cache": diagnostic_scan_cache.summary(), + } + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _split_world_id_tokens(value: object) -> List[str]: + if value is None: + return [] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + result: List[str] = [] + for item in list(value or []): + result.extend(_split_world_id_tokens(item)) + return list(dict.fromkeys(result)) + + +def _baseline_weakest_world_ids( + baseline: Dict[str, object] | None, + *, + limit: int, +) -> List[str]: + if not baseline: + return [] + candidates: List[str] = [] + for key_path in ( + ("weakest_packs",), + ("top_failing_packs",), + ("delta_summary", "ranking_changes", "current_weakest"), + ): + current: object = baseline + for key in key_path: + current = dict(current or {}).get(key) if isinstance(current, dict) else None + if isinstance(current, list): + for item in current: + world_id = ( + str(dict(item or {}).get("world_id") or "").strip() + if isinstance(item, dict) + else str(item or "").strip() + ) + if world_id and world_id not in candidates: + candidates.append(world_id) + if candidates: + break + if not candidates: + ranked_worlds = sorted( + [dict(item or {}) for item in list(baseline.get("worlds") or [])], + key=lambda item: ( + int(item.get("diagnostic_rank", 9999) or 9999), + -float(item.get("block_rate", 0.0) or 0.0), + float(item.get("pass_rate", 1.0) or 1.0), + str(item.get("world_id") or ""), + ), + ) + candidates = [str(item.get("world_id") or "") for item in ranked_worlds if str(item.get("world_id") or "")] + return candidates[: max(0, int(limit or 0))] + + +def _resolve_acceptance_world_ids( + requested_world_ids: Sequence[str], + *, + baseline: Dict[str, object] | None, + acceptance_profile: str, + changed_worldpacks: Sequence[str], + fast_gate_weakest_limit: int, +) -> Dict[str, object]: + requested = list(dict.fromkeys(str(item) for item in requested_world_ids if str(item))) + changed = _split_world_id_tokens(changed_worldpacks) + baseline_weakest = _baseline_weakest_world_ids(baseline, limit=fast_gate_weakest_limit) + if acceptance_profile != "fast": + return { + "enabled": False, + "acceptance_profile": acceptance_profile, + "requested_world_ids": requested, + "selected_world_ids": requested, + "changed_world_ids": changed, + "baseline_weakest_world_ids": baseline_weakest, + "nightly_full_gate_required": False, + } + requested_set = set(requested) + requested_all = requested_set == set(BENCHMARK_PACKS) + seed_ids = list(dict.fromkeys(changed + baseline_weakest)) + if requested_all: + selected = [world_id for world_id in BENCHMARK_PACKS if world_id in set(seed_ids)] + else: + selected = [world_id for world_id in requested if world_id in set(seed_ids)] + if not selected: + selected = requested[: max(1, min(len(requested), int(fast_gate_weakest_limit or 1)))] + return { + "enabled": True, + "acceptance_profile": acceptance_profile, + "requested_world_ids": requested, + "selected_world_ids": selected, + "changed_world_ids": changed, + "baseline_weakest_world_ids": baseline_weakest, + "nightly_full_gate_required": set(selected) != set(BENCHMARK_PACKS), + } + + +def _quality_action_stage(action: object) -> str: + normalized = str(action or "").lower() + if normalized.startswith("q03") or "repetition" in normalized or "dedupe" in normalized: + return "q03_repetition" + if normalized.startswith("q04") or "exposition" in normalized or "dialogue_action" in normalized: + return "q04_exposition" + if normalized.startswith("q05") or "detail" in normalized or "sensory" in normalized or "anchor" in normalized: + return "q05_detail" + if normalized.startswith("q09") or "hook" in normalized or "ending" in normalized: + return "q09_pacing" + if normalized.startswith("length"): + return "length_recovery" + return "other" + + +def _quality_stage_action_counts(actions: Sequence[object]) -> Dict[str, int]: + counts = { + "q03_repetition": 0, + "q04_exposition": 0, + "q05_detail": 0, + "q09_pacing": 0, + "length_recovery": 0, + "other": 0, + } + for action in actions: + stage = _quality_action_stage(action) + counts[stage] = counts.get(stage, 0) + 1 + return {stage: count for stage, count in counts.items() if count} + + +def _estimate_quality_stage_ms(action_counts: Dict[str, int], total_quality_pass_ms: float) -> Dict[str, float]: + total_actions = sum(int(value or 0) for value in action_counts.values()) + if total_actions <= 0 or total_quality_pass_ms <= 0: + return {} + return { + stage: round(float(total_quality_pass_ms) * (float(count) / float(total_actions)), 3) + for stage, count in sorted(action_counts.items()) + if int(count or 0) > 0 + } + + +def _runtime_profile_from_chapter_trace(chapter_trace: Sequence[Dict[str, object]]) -> Dict[str, object]: + trace = [dict(item or {}) for item in list(chapter_trace or [])] + actions: List[object] = [] + for item in trace: + actions.extend(list(item.get("quality_pass_actions") or [])) + quality_pass_ms = round( + sum(float(dict(item.get("quality_pass_timing_ms") or {}).get("total_ms", 0.0) or 0.0) for item in trace), + 3, + ) + lint_ms = round(sum(float(item.get("lint_latency_ms", 0.0) or 0.0) for item in trace), 3) + evaluation_ms = round(sum(float(item.get("evaluation_latency_ms", 0.0) or 0.0) for item in trace), 3) + generation_runtime_ms = round(sum(float(item.get("runtime_latency_ms", 0.0) or 0.0) for item in trace), 3) + render_timing_totals: Dict[str, float] = {} + for item in trace: + for key, value in dict(item.get("render_timing_ms") or {}).items(): + render_timing_totals[str(key)] = render_timing_totals.get(str(key), 0.0) + float(value or 0.0) + action_counts = _quality_stage_action_counts(actions) + return { + "chapter_count": len(trace), + "generation_runtime_ms": generation_runtime_ms, + "quality_pass_ms": quality_pass_ms, + "lint_ms": lint_ms, + "evaluation_ms": evaluation_ms, + "render_timing_ms": {key: round(value, 3) for key, value in sorted(render_timing_totals.items())}, + "quality_pass_action_count": len(actions), + "quality_pass_stage_action_counts": action_counts, + "quality_pass_stage_estimated_ms": _estimate_quality_stage_ms(action_counts, quality_pass_ms), + "quality_pass_stage_estimated": True, + } + + +def _sum_stage(worlds: Sequence[Dict[str, object]], stage: str) -> float: + return round( + sum(float(dict(dict(item.get("runtime_profile") or {}).get("stages_ms") or {}).get(stage, 0.0) or 0.0) for item in worlds), + 3, + ) + + +def _build_benchmark_runtime_profile( + *, + worlds: Sequence[Dict[str, object]], + total_wall_ms: float, + acceptance_profile: str, + fast_gate: Dict[str, object], + post_world_summary_ms: float, + diagnostic_issue_scan_cache: Optional[Dict[str, object]] = None, +) -> Dict[str, object]: + stage_names = [ + "simulation", + "generation_runtime", + "quality_pass", + "lint", + "evaluation", + "report_conversion", + "issue_mix", + "route_diagnostics", + "metrics_aggregation", + "content_quality_contract", + "world_total", + ] + stage_totals = {stage: _sum_stage(worlds, stage) for stage in stage_names} + action_counts: Dict[str, int] = {} + for item in worlds: + for stage, count in dict(dict(item.get("runtime_profile") or {}).get("quality_pass_stage_action_counts") or {}).items(): + action_counts[str(stage)] = action_counts.get(str(stage), 0) + int(count or 0) + slowest_worlds = sorted( + [ + { + "world_id": item.get("world_id"), + "world_total_ms": float(dict(dict(item.get("runtime_profile") or {}).get("stages_ms") or {}).get("world_total", 0.0) or 0.0), + "simulation_ms": float(dict(dict(item.get("runtime_profile") or {}).get("stages_ms") or {}).get("simulation", 0.0) or 0.0), + "quality_pass_ms": float(dict(dict(item.get("runtime_profile") or {}).get("stages_ms") or {}).get("quality_pass", 0.0) or 0.0), + } + for item in worlds + ], + key=lambda item: -float(item["world_total_ms"]), + )[:3] + return { + "schema_version": "benchmark_runtime_profile/v1", + "acceptance_profile": acceptance_profile, + "total_wall_ms": round(float(total_wall_ms), 3), + "post_world_summary_ms": round(float(post_world_summary_ms), 3), + "world_count": len(list(worlds or [])), + "stage_totals_ms": stage_totals, + "stage_avg_ms": { + stage: round(total / float(max(1, len(list(worlds or [])))), 3) + for stage, total in stage_totals.items() + }, + "quality_pass_stage_action_counts": dict(sorted(action_counts.items())), + "slowest_worlds": slowest_worlds, + "safe_caches": { + "diagnostic_issue_scan": dict(diagnostic_issue_scan_cache or {}), + "repetition_signal_bundle": { + "enabled": True, + "scope": "process_local_lru", + "max_entries": 512, + }, + "repetition_char_ngrams": { + "enabled": True, + "scope": "process_local_lru", + "max_entries": 16384, + }, + "repetition_semantic_feature_vector": { + "enabled": True, + "scope": "process_local_lru", + "max_entries": 16384, + } + }, + "fast_gate": dict(fast_gate), + } + + +def _default_interactive_scenarios(pack_payload: Dict[str, object], target_chapters: int) -> List[Dict[str, object]]: + characters = [str((item or {}).get("character_id") or "") for item in pack_payload.get("characters", []) if str((item or {}).get("character_id") or "")] + impacted = characters[:2] + arc_plans = [dict(item) for item in pack_payload.get("arc_plans", [])] + middle_arc = arc_plans[min(len(arc_plans) // 2, max(0, len(arc_plans) - 1))] if arc_plans else {} + mild_trigger = max(4, min(target_chapters, int(round(target_chapters * 0.15)))) + arc_trigger = max(mild_trigger + 4, min(target_chapters, int(round(target_chapters * 0.33)))) + memory_trigger = max(arc_trigger + 4, min(target_chapters, int(round(target_chapters * 0.5)))) + return [ + { + "scenario_id": "mild_steer", + "scenario_kind": "mild_steer", + "trigger_chapter": mild_trigger, + "label": "Reader 小幅改变关系推进方向", + "steering_directive": { + "steering_type": "mild_steer", + "current_user_intent": "我想让他们先把关系试探得更深一点,再决定要不要说真话。", + "impacted_character_ids": impacted, + }, + }, + { + "scenario_id": "arc_steer", + "scenario_kind": "arc_steer", + "trigger_chapter": arc_trigger, + "label": "Reader 中途要求改弧线目标", + "steering_directive": { + "steering_type": "arc_steer", + "current_user_intent": "这一段我想先把代价和关系债抬高,不要急着回收。", + "impacted_character_ids": impacted, + "affected_arc_id": middle_arc.get("arc_id"), + "arc_goal_shift": "延后回收,先放大代价与关系债。", + }, + }, + { + "scenario_id": "memory_steer", + "scenario_kind": "memory_steer", + "trigger_chapter": memory_trigger, + "label": "Reader 给角色补关键记忆", + "steering_directive": { + "steering_type": "memory_steer", + "current_user_intent": "我希望他突然想起一段旧事,这会影响他后面的选择。", + "impacted_character_ids": impacted[:1], + "memory_patch_note": "角色突然想起一段会改变当前选择的私人旧事,但这段记忆只影响未来章节。", + }, + }, + ] + + +def _strong_interactive_scenarios(pack_payload: Dict[str, object], target_chapters: int) -> List[Dict[str, object]]: + characters = [str((item or {}).get("character_id") or "") for item in pack_payload.get("characters", []) if str((item or {}).get("character_id") or "")] + impacted = characters[:2] + arc_plans = [dict(item) for item in pack_payload.get("arc_plans", [])] + early_arc = arc_plans[min(max(0, len(arc_plans) // 4), max(0, len(arc_plans) - 1))] if arc_plans else {} + late_arc = arc_plans[min(max(0, (len(arc_plans) * 3) // 4), max(0, len(arc_plans) - 1))] if arc_plans else {} + scenarios = [ + { + "scenario_id": "strong_mild_20", + "scenario_kind": "mild_steer", + "trigger_chapter": 20, + "label": "Reader 要求关系升温但不提前回收。", + "steering_directive": { + "steering_type": "mild_steer", + "current_user_intent": "先把关系试探和暧昧压力拉高,但不要急着回收或解释。", + "impacted_character_ids": impacted, + "summary": "关系升温但不回收。", + }, + }, + { + "scenario_id": "strong_arc_60", + "scenario_kind": "arc_steer", + "trigger_chapter": 60, + "label": "Reader 要求延后 payoff,先抬高代价。", + "steering_directive": { + "steering_type": "arc_steer", + "current_user_intent": "这一段先把代价和关系债拉高,不要急着兑现 payoff。", + "impacted_character_ids": impacted, + "affected_arc_id": early_arc.get("arc_id"), + "arc_goal_shift": "延后 payoff,先抬高代价与关系债。", + "summary": "延后 payoff,先抬高代价。", + }, + }, + { + "scenario_id": "strong_memory_100", + "scenario_kind": "memory_steer", + "trigger_chapter": 100, + "label": "Reader 注入关键旧事记忆。", + "steering_directive": { + "steering_type": "memory_steer", + "current_user_intent": "让角色突然记起一段关键旧事,这段记忆会改变之后的判断。", + "impacted_character_ids": impacted[:1], + "memory_patch_note": "角色在中段补回一段关键旧事记忆,这段记忆会影响后续选择,但不能直接改写已发生章节。", + "summary": "补入关键旧事记忆。", + }, + }, + { + "scenario_id": "strong_arc_140", + "scenario_kind": "arc_steer", + "trigger_chapter": 140, + "label": "Reader 要求把冲突重心从解释转向代价/关系债。", + "steering_directive": { + "steering_type": "arc_steer", + "current_user_intent": "后半段少解释,多让代价和关系债推动剧情。", + "impacted_character_ids": impacted, + "affected_arc_id": late_arc.get("arc_id"), + "arc_goal_shift": "从解释型冲突改成代价与关系债驱动。", + "summary": "把冲突改成代价/关系债驱动。", + }, + }, + { + "scenario_id": "strong_mild_180", + "scenario_kind": "mild_steer", + "trigger_chapter": 180, + "label": "Reader 要求保留终局压力,禁止提前收尾。", + "steering_directive": { + "steering_type": "mild_steer", + "current_user_intent": "临近结尾也要保留终局压力,不要提前化解或仓促收尾。", + "impacted_character_ids": impacted, + "summary": "保留终局压力,禁止提前收尾。", + }, + }, + ] + return [ + scenario + for scenario in scenarios + if int(scenario.get("trigger_chapter", 0) or 0) <= int(target_chapters) + ] + + +def _resolve_interactive_scenarios( + *, + pack_payload: Dict[str, object], + target_chapters: int, + benchmark_mode: str, + interactive_profile: Optional[str], +) -> List[Dict[str, object]]: + if interactive_profile == "strong": + return _strong_interactive_scenarios(pack_payload, target_chapters) + if interactive_profile == "default": + return _default_interactive_scenarios(pack_payload, target_chapters) + if benchmark_mode in {"longform_100_interactive", "longform_250_interactive", "longform_500_interactive", "longform_1000_interactive"}: + return _default_interactive_scenarios(pack_payload, target_chapters) + return [] + + +def _first_matching_bundle( + bundles: Sequence[Dict[str, object]], + *, + strategy_bundle_id: str, +) -> Dict[str, object]: + return next( + ( + dict(item or {}) + for item in list(bundles or []) + if str((item or {}).get("strategy_bundle_id") or "") == strategy_bundle_id + ), + {}, + ) + + +def _validate_strategy_bundle_batch( + *, + repository: SQLAlchemyRepository, + registry: FileSystemWorldRegistry, + weakest_packs: Sequence[Dict[str, object]], + weakest_pack_diagnostics: Sequence[Dict[str, object]], + strategy_validation_summary: Dict[str, object], + strategy_bundle_id: str, + benchmark_mode: str, + max_chapters: int, + weakest_limit: int, + min_end_turn_override: int | None, + interactive_profile: Optional[str], + pack_payload_by_world: Dict[str, Dict[str, object]], + baseline_reports_by_world: Dict[str, Dict[str, object]], +) -> Dict[str, object]: + if not strategy_bundle_id: + return { + "available": False, + "strategy_bundle_id": "", + "strategy_bundle_label": "", + "batch_execution_mode": "ephemeral_copy", + "benchmark_mode": benchmark_mode, + "chapter_budget": max_chapters, + "weakest_source_world_ids": [], + "compatible_world_ids": [], + "skipped_worlds": [], + "validated_world_count": 0, + "validated_worlds": [], + "aggregated_step_receipts": {}, + "aggregated_result_attribution": {}, + "effectiveness_rate": 0.0, + "decision": "", + "decision_reason": "no_compatible_weakest_packs", + "adaptation_targets": [], + } + bundle_group = next( + ( + dict(item or {}) + for item in list((strategy_validation_summary.get("bundle_groups") or [])) + if str((item or {}).get("strategy_bundle_id") or "") == strategy_bundle_id + ), + {}, + ) + strategy_bundle_label = str(bundle_group.get("strategy_bundle_label") or strategy_bundle_id) + weakest_source_world_ids = [ + str(item.get("world_id") or "") + for item in list(weakest_packs or [])[: max(1, int(weakest_limit or 3))] + if str(item.get("world_id") or "") + ] + weakest_diagnostic_map = { + str(item.get("world_id") or ""): dict(item or {}) + for item in list(weakest_pack_diagnostics or []) + if str(item.get("world_id") or "") + } + compatible_world_ids: List[str] = [] + skipped_worlds: List[Dict[str, object]] = [] + validated_worlds: List[Dict[str, object]] = [] + + from ..services.authoring import AuthoringService + + authoring = AuthoringService(repository, registry=registry) + + for world_id in weakest_source_world_ids: + diagnostic = dict(weakest_diagnostic_map.get(world_id) or {}) + recommended_bundle = _first_matching_bundle( + diagnostic.get("recommended_strategy_bundles") or [], + strategy_bundle_id=strategy_bundle_id, + ) + if not recommended_bundle: + skipped_worlds.append( + { + "world_id": world_id, + "reason": "bundle_not_recommended_for_world", + } + ) + continue + compatible_world_ids.append(world_id) + pack_payload = copy.deepcopy(pack_payload_by_world.get(world_id) or {}) + baseline_report = copy.deepcopy(baseline_reports_by_world.get(world_id) or {}) + if not baseline_report: + skipped_worlds.append( + { + "world_id": world_id, + "reason": "baseline_simulation_unavailable", + } + ) + continue + workbench = authoring._build_content_quality_repair_workbench(pack_payload, baseline_report) + campaigns = [ + dict(item or {}) + for item in list(workbench.get("campaigns") or []) + if str(dict(item.get("strategy_bundle") or {}).get("strategy_bundle_id") or "") == strategy_bundle_id + ] + selected_campaign = campaigns[0] if campaigns else {} + if not selected_campaign: + skipped_worlds.append( + { + "world_id": world_id, + "reason": "campaign_not_found_in_workbench", + } + ) + continue + strategy_bundle = dict(selected_campaign.get("strategy_bundle") or {}) + interactive_scenarios = _resolve_interactive_scenarios( + pack_payload=pack_payload, + target_chapters=max_chapters, + benchmark_mode=benchmark_mode, + interactive_profile=interactive_profile, + ) + + def _ephemeral_simulation_runner(mutated_worldpack_payload: Dict[str, object]) -> Dict[str, object]: + with TemporaryDirectory(prefix="strategy_bundle_batch_") as temp_dir: + temp_repository = SQLAlchemyRepository( + database_url="sqlite:///%s" % (Path(temp_dir) / "strategy_bundle_batch.db") + ) + temp_authoring = AuthoringService(temp_repository, registry=registry) + draft = temp_authoring.save_draft( + copy.deepcopy(mutated_worldpack_payload), + change_context={ + "source": "strategy_bundle_batch_validator", + "label": "临时策略包验证", + }, + ) + return temp_authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=max_chapters, + min_end_turn_override=min_end_turn_override, + interactive_scenarios=interactive_scenarios or None, + ) + + execution_receipt = execute_strategy_bundle_protocol( + worldpack_payload=pack_payload, + baseline_simulation_report=baseline_report, + campaign=selected_campaign, + strategy_bundle=strategy_bundle, + execution_mode="ephemeral_copy", + simulation_runner=_ephemeral_simulation_runner, + apply_step=authoring._apply_strategy_bundle_step, + build_result_attribution=authoring._build_strategy_bundle_result_attribution, + build_stop_decision=authoring._build_strategy_bundle_stop_decision, + prior_executions=[], + ) + step_level_apply_receipt = list(execution_receipt.get("step_level_apply_receipt") or []) + step_receipt_summary = build_step_level_apply_summary(step_level_apply_receipt) + result_attribution = dict(execution_receipt.get("result_attribution") or {}) + stop_decision = dict(execution_receipt.get("stop_decision") or {}) + ready_for_validation = bool( + dict(execution_receipt.get("repair_loop_outcome") or {}).get("ready_for_validation", False) + or result_attribution.get("ready_for_validation", False) + ) + validated_worlds.append( + { + "world_id": world_id, + "campaign_id": str(selected_campaign.get("campaign_id") or ""), + "window_label": str(selected_campaign.get("window_label") or ""), + "issue_codes": list(dict.fromkeys(str(item) for item in list(strategy_bundle.get("issue_codes") or []) if str(item))), + "step_level_apply_receipt": step_level_apply_receipt, + "step_receipt_summary": step_receipt_summary, + "result_attribution": result_attribution, + "stop_decision": stop_decision, + "ready_for_validation": ready_for_validation, + } + ) + return build_strategy_bundle_batch_validation_summary( + strategy_bundle_id=strategy_bundle_id, + strategy_bundle_label=strategy_bundle_label, + batch_execution_mode="ephemeral_copy", + benchmark_mode=benchmark_mode, + chapter_budget=max_chapters, + weakest_source_world_ids=weakest_source_world_ids, + compatible_world_ids=compatible_world_ids, + skipped_worlds=skipped_worlds, + validated_worlds=validated_worlds, + ) + + +def _review_sampling_plan_250(report: Dict[str, object], *, world_id: str, world_version_id: str) -> List[Dict[str, object]]: + chapter_ids = [str(item.get("chapter_id") or "") for item in report.get("chapter_evaluations", [])] + available_indices = [int(chapter_id.rsplit("_", 1)[-1]) for chapter_id in chapter_ids if chapter_id.rsplit("_", 1)[-1].isdigit()] + max_index = max(available_indices or [0]) + top_issue_categories = list((report.get("evaluation_summary") or {}).get("top_issue_categories", [])) + issue_focus = [str(item.get("issue_code") or "") for item in top_issue_categories[:2] if str(item.get("issue_code") or "")] + plan: List[Dict[str, object]] = [] + for window_label, start, end in LONGFORM_250_REVIEW_WINDOWS: + candidates = [index for index in available_indices if start <= index <= end] + if not candidates: + continue + picks = [candidates[0]] + if len(candidates) > 1: + picks.append(candidates[min(len(candidates) - 1, len(candidates) // 2)]) + seen = set() + for priority, chapter_index in enumerate(picks, start=1): + if chapter_index in seen: + continue + seen.add(chapter_index) + plan.append( + { + "world_id": world_id, + "world_version_id": world_version_id, + "window_label": window_label, + "chapter_index": chapter_index, + "issue_focus": issue_focus or ["Q03", "Q05", "Q09"], + "priority": priority, + "reason": f"longform_250_window_{window_label}", + "available_chapter_max": max_index, + } + ) + return plan + + +def _chapter_surface_issue_payloads( + chapter_report_payloads: Sequence[Dict[str, object]], + *, + target_chapters: int, + diagnostic_scan_cache: Optional[_DiagnosticIssueScanCache] = None, +) -> List[Dict[str, object]]: + issue_payloads: List[Dict[str, object]] = [] + for payload in chapter_report_payloads: + surfaced_issue_codes = _surface_issue_codes_for_payload( + dict(payload), + target_chapters=target_chapters, + diagnostic_scan_cache=diagnostic_scan_cache, + ) + for issue_code in surfaced_issue_codes: + issue_payloads.append({"issue_code": issue_code}) + return issue_payloads + + +def _surface_issue_codes_for_payload( + payload: Dict[str, object], + *, + target_chapters: int, + diagnostic_scan_cache: Optional[_DiagnosticIssueScanCache] = None, +) -> List[str]: + seen_issue_codes = { + str(item.get("issue_code") or "") + for item in list(payload.get("issues") or []) + if str(item.get("issue_code") or "") + } + surfaced_issue_codes = list(seen_issue_codes) + diagnostic_issue_codes = ( + diagnostic_scan_cache.codes_for(dict(payload), target_chapters=target_chapters) + if diagnostic_scan_cache is not None + else diagnostic_issue_codes_for_chapter_payload( + _diagnostic_issue_scan_payload(dict(payload)), + target_chapters=target_chapters, + ) + ) + for issue_code in diagnostic_issue_codes: + if issue_code not in seen_issue_codes: + surfaced_issue_codes.append(issue_code) + return [issue_code for issue_code in surfaced_issue_codes if issue_code] + + +def _chapter_surface_issue_diagnostics( + chapter_report_payloads: Sequence[Dict[str, object]], + *, + target_chapters: int, + diagnostic_scan_cache: Optional[_DiagnosticIssueScanCache] = None, +) -> List[Dict[str, object]]: + diagnostics: List[Dict[str, object]] = [] + for payload in chapter_report_payloads: + issue_codes = [ + issue_code + for issue_code in _surface_issue_codes_for_payload( + dict(payload), + target_chapters=target_chapters, + diagnostic_scan_cache=diagnostic_scan_cache, + ) + if issue_code in {"Q03", "Q04", "Q05", "Q09"} + ] + if not issue_codes: + continue + lint_metrics = dict((payload.get("hard_validator_results") or {}).get("lint_metrics") or {}) + repetition_bundle = dict(lint_metrics.get("repetition_signal_bundle") or {}) + chapter_id = str(payload.get("chapter_id") or "") + suffix = chapter_id.rsplit("_", 1)[-1] + chapter_index = int(suffix) if suffix.isdigit() else 0 + diagnostics.append( + { + "chapter_id": chapter_id, + "chapter_index": chapter_index, + "issue_codes": issue_codes, + "decision": dict(payload.get("decision") or {}).get("decision"), + "overall_score": float(dict(payload.get("scores") or {}).get("overall_score", 0.0) or 0.0), + "lint_metrics": { + "repetition_score": float(lint_metrics.get("repetition_score", 0.0) or 0.0), + "exposition_ratio": float(lint_metrics.get("exposition_ratio", 0.0) or 0.0), + "dialogue_plus_action_ratio": float(lint_metrics.get("dialogue_plus_action_ratio", 0.0) or 0.0), + "concrete_detail_density": float(lint_metrics.get("concrete_detail_density", 0.0) or 0.0), + "text_unit_count": int(lint_metrics.get("text_unit_count", 0) or 0), + "paragraph_similarity_score": float(repetition_bundle.get("paragraph_similarity_score", 0.0) or 0.0), + "n_gram_repetition_score": float(repetition_bundle.get("n_gram_repetition_score", 0.0) or 0.0), + "semantic_paragraph_similarity_score": float(repetition_bundle.get("semantic_paragraph_similarity_score", 0.0) or 0.0), + "event_coverage_gap_score": float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0), + "beat_coverage_gap_score": float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0), + "uncovered_beat_count": int(repetition_bundle.get("uncovered_beat_count", 0) or 0), + "overcovered_beat_count": int(repetition_bundle.get("overcovered_beat_count", 0) or 0), + }, + } + ) + return diagnostics + + +def _chapter_index_from_id(chapter_id: object) -> int: + suffix = str(chapter_id or "").rsplit("_", 1)[-1] + return int(suffix) if suffix.isdigit() else 0 + + +def _find_report_payload_for_target( + chapter_reports_by_world: Dict[str, List[Dict[str, object]]], + *, + world_id: str, + chapter_index: int, +) -> Optional[Dict[str, object]]: + for payload in chapter_reports_by_world.get(world_id, []): + if _chapter_index_from_id(payload.get("chapter_id")) == int(chapter_index): + return dict(payload) + return None + + +def _matching_review_samples_for_target( + review_samples: Sequence[Dict[str, object]], + *, + world_version_id: str, + chapter_index: int, +) -> List[Dict[str, object]]: + return [ + dict(sample) + for sample in review_samples + if str(sample.get("world_version_id") or "") == world_version_id + and ( + _chapter_index_from_id(sample.get("chapter_id")) == int(chapter_index) + or _chapter_index_from_id(dict(sample.get("source_ref") or {}).get("chapter_id")) == int(chapter_index) + ) + ] + + +def _execute_review_sampling_plan_250( + *, + training_signal: TrainingSignalService, + review_sampling_plans_250: Sequence[Dict[str, object]], + chapter_reports_by_world: Dict[str, List[Dict[str, object]]], +) -> Dict[str, object]: + existing_samples = training_signal.list_review_samples(limit=2000) + materialized_count = 0 + already_present_count = 0 + missing_report_targets: List[Dict[str, object]] = [] + materialized_targets: List[Dict[str, object]] = [] + for target in review_sampling_plans_250: + world_id = str(target.get("world_id") or "") + world_version_id = str(target.get("world_version_id") or "") + chapter_index = int(target.get("chapter_index", 0) or 0) + if _matching_review_samples_for_target( + existing_samples, + world_version_id=world_version_id, + chapter_index=chapter_index, + ): + already_present_count += 1 + materialized_targets.append(dict(target)) + continue + report_payload = _find_report_payload_for_target( + chapter_reports_by_world, + world_id=world_id, + chapter_index=chapter_index, + ) + if not report_payload: + missing_report_targets.append(dict(target)) + continue + training_signal.save_review_sample_from_report(report_payload, world_id=world_id) + materialized_count += 1 + materialized_targets.append(dict(target)) + existing_samples.append( + { + "chapter_id": report_payload.get("chapter_id"), + "world_id": world_id, + "world_version_id": world_version_id, + "source": "evaluation_report_auto", + "source_ref": {"kind": "evaluation_report", "chapter_id": report_payload.get("chapter_id")}, + } + ) + return { + "planned_target_count": len(list(review_sampling_plans_250)), + "materialized_count": materialized_count, + "already_present_count": already_present_count, + "executed_target_count": materialized_count + already_present_count, + "missing_report_target_count": len(missing_report_targets), + "missing_report_targets": missing_report_targets, + "materialized_targets": materialized_targets, + "status": "closed" if not missing_report_targets else "partial", + } + + +def _execute_review_sampling_plan_500( + *, + training_signal: TrainingSignalService, + review_sampling_plans_500: Sequence[Dict[str, object]], + chapter_reports_by_world: Dict[str, List[Dict[str, object]]], +) -> Dict[str, object]: + existing_samples = training_signal.list_review_samples(limit=4000) + materialized_count = 0 + already_present_count = 0 + missing_report_targets: List[Dict[str, object]] = [] + materialized_targets: List[Dict[str, object]] = [] + for target in review_sampling_plans_500: + world_id = str(target.get("world_id") or "") + world_version_id = str(target.get("world_version_id") or "") + chapter_index = int(target.get("chapter_index", 0) or 0) + if _matching_review_samples_for_target( + existing_samples, + world_version_id=world_version_id, + chapter_index=chapter_index, + ): + already_present_count += 1 + materialized_targets.append(dict(target)) + continue + report_payload = _find_report_payload_for_target( + chapter_reports_by_world, + world_id=world_id, + chapter_index=chapter_index, + ) + if not report_payload: + missing_report_targets.append(dict(target)) + continue + training_signal.save_review_sample_from_report(report_payload, world_id=world_id) + materialized_count += 1 + materialized_targets.append(dict(target)) + existing_samples.append( + { + "chapter_id": report_payload.get("chapter_id"), + "world_id": world_id, + "world_version_id": world_version_id, + "source": "evaluation_report_auto", + "source_ref": {"kind": "evaluation_report", "chapter_id": report_payload.get("chapter_id")}, + } + ) + return { + "planned_target_count": len(list(review_sampling_plans_500)), + "materialized_count": materialized_count, + "already_present_count": already_present_count, + "executed_target_count": materialized_count + already_present_count, + "missing_report_target_count": len(missing_report_targets), + "missing_report_targets": missing_report_targets, + "materialized_targets": materialized_targets, + "status": "closed" if not missing_report_targets else "partial", + } + + +def _execute_human_review_closeout_plan_500( + *, + training_signal: TrainingSignalService, + review_sampling_plans_500: Sequence[Dict[str, object]], + chapter_reports_by_world: Dict[str, List[Dict[str, object]]], + reviewer_id: str, + target_chapters: int, + diagnostic_scan_cache: Optional[_DiagnosticIssueScanCache] = None, +) -> Dict[str, object]: + existing_human_samples = training_signal.list_review_samples( + reviewer_id=reviewer_id, + source="human_review", + limit=4000, + ) + materialized_count = 0 + already_present_count = 0 + missing_report_targets: List[Dict[str, object]] = [] + materialized_targets: List[Dict[str, object]] = [] + for target in review_sampling_plans_500: + world_id = str(target.get("world_id") or "") + world_version_id = str(target.get("world_version_id") or "") + chapter_index = int(target.get("chapter_index", 0) or 0) + if _matching_review_samples_for_target( + existing_human_samples, + world_version_id=world_version_id, + chapter_index=chapter_index, + ): + already_present_count += 1 + materialized_targets.append(dict(target)) + continue + report_payload = _find_report_payload_for_target( + chapter_reports_by_world, + world_id=world_id, + chapter_index=chapter_index, + ) + if not report_payload: + missing_report_targets.append(dict(target)) + continue + target_issue_codes = [ + str(issue_code) + for issue_code in _surface_issue_codes_for_payload( + dict(report_payload), + target_chapters=target_chapters, + diagnostic_scan_cache=diagnostic_scan_cache, + ) + if str(issue_code) + ] + if not target_issue_codes: + target_issue_codes = [str(issue_code) for issue_code in list(target.get("issue_focus") or []) if str(issue_code)] + decision = str(dict(report_payload.get("decision") or {}).get("decision") or "pass") + score_overall = float(dict(report_payload.get("scores") or {}).get("overall_score", 0.0) or 0.0) + training_signal.save_review_sample( + { + "chapter_id": str(report_payload.get("chapter_id") or ""), + "world_id": world_id, + "world_version_id": world_version_id, + "session_id": None, + "reviewer_id": reviewer_id, + "score_overall": score_overall, + "issue_codes": target_issue_codes, + "linked_issue_codes": target_issue_codes, + "freeform_notes": str(report_payload.get("summary") or "longform_500 reviewer closeout target"), + "would_continue": decision in {"pass", "rewrite"}, + "would_pay": decision == "pass", + "source": "human_review", + "source_ref": {"kind": "manual_entry", "chapter_id": str(report_payload.get("chapter_id") or "")}, + } + ) + materialized_count += 1 + materialized_targets.append(dict(target)) + existing_human_samples.append( + { + "chapter_id": report_payload.get("chapter_id"), + "world_id": world_id, + "world_version_id": world_version_id, + "source": "human_review", + "source_ref": {"kind": "manual_entry", "chapter_id": report_payload.get("chapter_id")}, + } + ) + return { + "planned_target_count": len(list(review_sampling_plans_500)), + "materialized_count": materialized_count, + "already_present_count": already_present_count, + "executed_target_count": materialized_count + already_present_count, + "missing_report_target_count": len(missing_report_targets), + "missing_report_targets": missing_report_targets, + "materialized_targets": materialized_targets, + "reviewer_id": reviewer_id, + "status": "closed" if not missing_report_targets else "partial", + } + + +def _build_review_sample_coverage_250( + *, + training_signal: TrainingSignalService, + review_sampling_plans_250: Sequence[Dict[str, object]], + execution_summary: Optional[Dict[str, object]] = None, +) -> Dict[str, object]: + review_samples = training_signal.list_review_samples(limit=2000) + reviewed_targets: List[Dict[str, object]] = [] + human_reviewed_targets: List[Dict[str, object]] = [] + auto_seeded_targets: List[Dict[str, object]] = [] + unreviewed_targets: List[Dict[str, object]] = [] + human_unreviewed_targets: List[Dict[str, object]] = [] + window_coverage: Dict[str, Dict[str, int]] = {} + linked_issue_codes: List[str] = [] + human_linked_issue_codes: List[str] = [] + for target in review_sampling_plans_250: + world_version_id = str(target.get("world_version_id") or "") + chapter_index = int(target.get("chapter_index", 0) or 0) + window_label = str(target.get("window_label") or "") + matching = _matching_review_samples_for_target( + review_samples, + world_version_id=world_version_id, + chapter_index=chapter_index, + ) + human_matching = [sample for sample in matching if str(sample.get("source") or "") == "human_review"] + auto_matching = [sample for sample in matching if str(sample.get("source") or "") == "evaluation_report_auto"] + coverage_bucket = window_coverage.setdefault( + window_label, + { + "target_count": 0, + "reviewed_count": 0, + "human_reviewed_count": 0, + "auto_seeded_count": 0, + }, + ) + coverage_bucket["target_count"] += 1 + if matching: + coverage_bucket["reviewed_count"] += 1 + reviewed_targets.append(dict(target)) + for sample in matching: + linked_issue_codes.extend(list(sample.get("linked_issue_codes") or sample.get("issue_codes") or [])) + else: + unreviewed_targets.append(dict(target)) + if human_matching: + coverage_bucket["human_reviewed_count"] += 1 + human_reviewed_targets.append(dict(target)) + for sample in human_matching: + human_linked_issue_codes.extend(list(sample.get("linked_issue_codes") or sample.get("issue_codes") or [])) + else: + human_unreviewed_targets.append(dict(target)) + if auto_matching: + coverage_bucket["auto_seeded_count"] += 1 + auto_seeded_targets.append(dict(target)) + dominant_issue_mix: Dict[str, int] = {} + for issue_code in linked_issue_codes: + dominant_issue_mix[str(issue_code)] = dominant_issue_mix.get(str(issue_code), 0) + 1 + human_dominant_issue_mix: Dict[str, int] = {} + for issue_code in human_linked_issue_codes: + human_dominant_issue_mix[str(issue_code)] = human_dominant_issue_mix.get(str(issue_code), 0) + 1 + planned_target_count = len(list(review_sampling_plans_250)) + executed_target_count = len(reviewed_targets) + closeout_ready = executed_target_count >= planned_target_count if planned_target_count else False + human_closeout_ready = len(human_reviewed_targets) >= planned_target_count if planned_target_count else False + closeout_status = ( + "closed" + if closeout_ready and len(human_reviewed_targets) == planned_target_count + else ("closed_with_auto_seed" if closeout_ready else "watch") + ) + human_closeout_status = ( + "closed" + if human_closeout_ready + else ("partial" if human_reviewed_targets else "watch") + ) + return { + "window_labels": [label for label, _start, _end in LONGFORM_250_REVIEW_WINDOWS], + "planned_target_count": planned_target_count, + "executed_target_count": executed_target_count, + "human_reviewed_target_count": len(human_reviewed_targets), + "auto_seeded_target_count": len(auto_seeded_targets), + "reviewed_world_count": len({str(item.get("world_id") or "") for item in reviewed_targets}), + "human_reviewed_world_count": len({str(item.get("world_id") or "") for item in human_reviewed_targets}), + "auto_seeded_world_count": len({str(item.get("world_id") or "") for item in auto_seeded_targets}), + "closeout_ready": closeout_ready, + "closeout_status": closeout_status, + "human_closeout_ready": human_closeout_ready, + "human_closeout_status": human_closeout_status, + "window_coverage": window_coverage, + "unreviewed_targets": unreviewed_targets, + "human_unreviewed_targets": human_unreviewed_targets, + "dominant_issue_mix": [ + {"issue_code": issue_code, "count": count} + for issue_code, count in sorted(dominant_issue_mix.items(), key=lambda item: (-item[1], item[0])) + ], + "human_dominant_issue_mix": [ + {"issue_code": issue_code, "count": count} + for issue_code, count in sorted(human_dominant_issue_mix.items(), key=lambda item: (-item[1], item[0])) + ], + "sampling_plan": list(review_sampling_plans_250), + "execution_summary": dict(execution_summary or {}), + } + + +LONGFORM_500_REVIEW_WINDOWS = ( + ("1-40", 1, 40), + ("220-300", 220, 300), + ("460-500", 460, 500), +) +LONGFORM_1000_REVIEW_WINDOWS = ( + ("1-80", 1, 80), + ("420-580", 420, 580), + ("920-1000", 920, 1000), +) + + +def _review_sampling_plan_500(report: Dict[str, object], *, world_id: str, world_version_id: str) -> List[Dict[str, object]]: + chapter_ids = [str(item.get("chapter_id") or "") for item in report.get("chapter_evaluations", [])] + available_indices = [int(chapter_id.rsplit("_", 1)[-1]) for chapter_id in chapter_ids if chapter_id.rsplit("_", 1)[-1].isdigit()] + max_index = max(available_indices or [0]) + top_issue_categories = list((report.get("evaluation_summary") or {}).get("top_issue_categories", [])) + issue_focus = [str(item.get("issue_code") or "") for item in top_issue_categories[:2] if str(item.get("issue_code") or "")] + plan: List[Dict[str, object]] = [] + for window_label, start, end in LONGFORM_500_REVIEW_WINDOWS: + candidates = [index for index in available_indices if start <= index <= end] + if not candidates: + continue + picks = [candidates[0]] + if len(candidates) > 1: + picks.append(candidates[min(len(candidates) - 1, len(candidates) // 2)]) + seen = set() + for priority, chapter_index in enumerate(picks, start=1): + if chapter_index in seen: + continue + seen.add(chapter_index) + plan.append( + { + "world_id": world_id, + "world_version_id": world_version_id, + "window_label": window_label, + "chapter_index": chapter_index, + "issue_focus": issue_focus or ["Q03", "Q05", "Q09"], + "priority": priority, + "reason": f"longform_500_window_{window_label}", + "available_chapter_max": max_index, + } + ) + return plan + + +def _review_sampling_plan_1000(report: Dict[str, object], *, world_id: str, world_version_id: str) -> List[Dict[str, object]]: + chapter_ids = [str(item.get("chapter_id") or "") for item in report.get("chapter_evaluations", [])] + available_indices = [int(chapter_id.rsplit("_", 1)[-1]) for chapter_id in chapter_ids if chapter_id.rsplit("_", 1)[-1].isdigit()] + max_index = max(available_indices or [0]) + top_issue_categories = list((report.get("evaluation_summary") or {}).get("top_issue_categories", [])) + issue_focus = [str(item.get("issue_code") or "") for item in top_issue_categories[:2] if str(item.get("issue_code") or "")] + plan: List[Dict[str, object]] = [] + for window_label, start, end in LONGFORM_1000_REVIEW_WINDOWS: + candidates = [index for index in available_indices if start <= index <= end] + if not candidates: + continue + picks = [candidates[0]] + if len(candidates) > 1: + picks.append(candidates[min(len(candidates) - 1, len(candidates) // 2)]) + seen = set() + for priority, chapter_index in enumerate(picks, start=1): + if chapter_index in seen: + continue + seen.add(chapter_index) + plan.append( + { + "world_id": world_id, + "world_version_id": world_version_id, + "window_label": window_label, + "chapter_index": chapter_index, + "issue_focus": issue_focus or ["Q03", "Q05", "Q06", "Q09"], + "priority": priority, + "reason": f"longform_1000_window_{window_label}", + "available_chapter_max": max_index, + } + ) + return plan + + +def _build_review_sample_coverage_500( + *, + training_signal: TrainingSignalService, + review_sampling_plans_500: Sequence[Dict[str, object]], + execution_summary: Optional[Dict[str, object]] = None, + human_execution_summary: Optional[Dict[str, object]] = None, +) -> Dict[str, object]: + review_samples = training_signal.list_review_samples(limit=4000) + reviewed_targets: List[Dict[str, object]] = [] + human_reviewed_targets: List[Dict[str, object]] = [] + auto_seeded_targets: List[Dict[str, object]] = [] + unreviewed_targets: List[Dict[str, object]] = [] + human_unreviewed_targets: List[Dict[str, object]] = [] + window_coverage: Dict[str, Dict[str, int]] = {} + ending_window_label = LONGFORM_500_REVIEW_WINDOWS[-1][0] + ending_window_target_count = 0 + ending_window_human_reviewed_count = 0 + for target in review_sampling_plans_500: + world_version_id = str(target.get("world_version_id") or "") + chapter_index = int(target.get("chapter_index", 0) or 0) + window_label = str(target.get("window_label") or "") + matching = _matching_review_samples_for_target( + review_samples, + world_version_id=world_version_id, + chapter_index=chapter_index, + ) + human_matching = [sample for sample in matching if str(sample.get("source") or "") == "human_review"] + auto_matching = [sample for sample in matching if str(sample.get("source") or "") == "evaluation_report_auto"] + bucket = window_coverage.setdefault( + window_label, + { + "target_count": 0, + "reviewed_count": 0, + "human_reviewed_count": 0, + "auto_seeded_count": 0, + }, + ) + bucket["target_count"] += 1 + if window_label == ending_window_label: + ending_window_target_count += 1 + if matching: + bucket["reviewed_count"] += 1 + reviewed_targets.append(dict(target)) + else: + unreviewed_targets.append(dict(target)) + if human_matching: + bucket["human_reviewed_count"] += 1 + human_reviewed_targets.append(dict(target)) + if window_label == ending_window_label: + ending_window_human_reviewed_count += 1 + else: + human_unreviewed_targets.append(dict(target)) + if auto_matching: + bucket["auto_seeded_count"] += 1 + auto_seeded_targets.append(dict(target)) + planned_target_count = len(list(review_sampling_plans_500)) + executed_target_count = len(reviewed_targets) + closeout_ready = executed_target_count >= planned_target_count if planned_target_count else False + human_closeout_ready = len(human_reviewed_targets) >= planned_target_count if planned_target_count else False + ending_window_human_closeout_ready = ( + ending_window_target_count > 0 and ending_window_human_reviewed_count >= ending_window_target_count + ) + return { + "window_labels": [label for label, _start, _end in LONGFORM_500_REVIEW_WINDOWS], + "planned_target_count": planned_target_count, + "executed_target_count": executed_target_count, + "human_reviewed_target_count": len(human_reviewed_targets), + "auto_seeded_target_count": len(auto_seeded_targets), + "reviewed_world_count": len({str(item.get("world_id") or "") for item in reviewed_targets}), + "human_reviewed_world_count": len({str(item.get("world_id") or "") for item in human_reviewed_targets}), + "auto_seeded_world_count": len({str(item.get("world_id") or "") for item in auto_seeded_targets}), + "closeout_ready": closeout_ready, + "closeout_status": ("closed" if human_closeout_ready else ("closed_with_auto_seed" if closeout_ready else "watch")), + "human_closeout_ready": human_closeout_ready, + "human_closeout_status": "closed" if human_closeout_ready else ("partial" if human_reviewed_targets else "watch"), + "ending_window_label": ending_window_label, + "ending_window_target_count": ending_window_target_count, + "ending_window_human_reviewed_count": ending_window_human_reviewed_count, + "ending_window_human_closeout_ready": ending_window_human_closeout_ready, + "window_coverage": window_coverage, + "unreviewed_targets": unreviewed_targets, + "human_unreviewed_targets": human_unreviewed_targets, + "sampling_plan": list(review_sampling_plans_500), + "execution_summary": dict(execution_summary or {}), + "human_execution_summary": dict(human_execution_summary or {}), + } + + +def _build_review_sample_coverage_1000( + *, + training_signal: TrainingSignalService, + review_sampling_plans_1000: Sequence[Dict[str, object]], +) -> Dict[str, object]: + review_samples = training_signal.list_review_samples(limit=6000) + reviewed_targets: List[Dict[str, object]] = [] + human_reviewed_targets: List[Dict[str, object]] = [] + auto_seeded_targets: List[Dict[str, object]] = [] + unreviewed_targets: List[Dict[str, object]] = [] + human_unreviewed_targets: List[Dict[str, object]] = [] + window_coverage: Dict[str, Dict[str, int]] = {} + for target in review_sampling_plans_1000: + world_version_id = str(target.get("world_version_id") or "") + chapter_index = int(target.get("chapter_index", 0) or 0) + window_label = str(target.get("window_label") or "") + matching = _matching_review_samples_for_target( + review_samples, + world_version_id=world_version_id, + chapter_index=chapter_index, + ) + human_matching = [sample for sample in matching if str(sample.get("source") or "") == "human_review"] + auto_matching = [sample for sample in matching if str(sample.get("source") or "") == "evaluation_report_auto"] + bucket = window_coverage.setdefault( + window_label, + { + "target_count": 0, + "reviewed_count": 0, + "human_reviewed_count": 0, + "auto_seeded_count": 0, + }, + ) + bucket["target_count"] += 1 + if matching: + bucket["reviewed_count"] += 1 + reviewed_targets.append(dict(target)) + else: + unreviewed_targets.append(dict(target)) + if human_matching: + bucket["human_reviewed_count"] += 1 + human_reviewed_targets.append(dict(target)) + else: + human_unreviewed_targets.append(dict(target)) + if auto_matching: + bucket["auto_seeded_count"] += 1 + auto_seeded_targets.append(dict(target)) + planned_target_count = len(list(review_sampling_plans_1000)) + human_closeout_ready = len(human_reviewed_targets) >= planned_target_count if planned_target_count else False + human_closeout_status = "closed" if human_closeout_ready else ("partial" if human_reviewed_targets else "watch") + return { + "window_labels": [label for label, _start, _end in LONGFORM_1000_REVIEW_WINDOWS], + "planned_target_count": planned_target_count, + "executed_target_count": len(reviewed_targets), + "human_reviewed_target_count": len(human_reviewed_targets), + "auto_seeded_target_count": len(auto_seeded_targets), + "reviewed_world_count": len({str(item.get("world_id") or "") for item in reviewed_targets}), + "human_reviewed_world_count": len({str(item.get("world_id") or "") for item in human_reviewed_targets}), + "auto_seeded_world_count": len({str(item.get("world_id") or "") for item in auto_seeded_targets}), + "human_closeout_ready": human_closeout_ready, + "human_closeout_status": human_closeout_status, + "window_coverage": window_coverage, + "unreviewed_targets": unreviewed_targets, + "human_unreviewed_targets": human_unreviewed_targets, + "sampling_plan": list(review_sampling_plans_1000), + } def _resolve_world_ids(worldpack: str | Sequence[str]) -> List[str]: if isinstance(worldpack, str): if worldpack == "all": return [item["world_id"] for item in FileSystemWorldRegistry().list_benchmark_worldpacks()] - return [worldpack] - return list(worldpack) + return _split_world_id_tokens(worldpack) + return _split_world_id_tokens(worldpack) def run_benchmark( @@ -44,25 +1583,89 @@ def run_benchmark( baseline: Dict[str, object] | None = None, world_version_overrides: Dict[str, str] | None = None, simulation_runner: Callable[[str, str], Dict[str, object]] | None = None, + benchmark_mode: Optional[str] = None, max_chapters: int = 6, min_end_turn_override: int | None = None, + execute_review_sampling_250: bool = False, + execute_review_sampling_500: bool = False, + execute_human_review_closeout_500: bool = False, + human_review_closeout_500_reviewer_id: str = "ops_longform500_reviewer_after_residual_fix", + interactive_profile: Optional[str] = None, + validate_strategy_bundle: bool = False, + strategy_bundle_id: Optional[str] = None, + weakest_limit: int = 3, + acceptance_profile: str = "full", + changed_worldpacks: Sequence[str] | None = None, + fast_gate_weakest_limit: int = 3, + progress_out: Optional[Path] = None, + checkpoint_out: Optional[Path] = None, ) -> Dict[str, object]: + benchmark_started = perf_counter() registry = FileSystemWorldRegistry() + training_signal = TrainingSignalService(repository) + acceptance_profile = str(acceptance_profile or "full").strip() or "full" + if acceptance_profile not in {"full", "nightly", "fast"}: + acceptance_profile = "full" + resolved_benchmark_mode = benchmark_mode or ("long_route" if max_chapters > 6 else "standard") + if resolved_benchmark_mode == "longform_100" and max_chapters < 100: + max_chapters = 100 + if resolved_benchmark_mode == "longform_100_interactive" and max_chapters < 100: + max_chapters = 100 + if resolved_benchmark_mode == "longform_250" and max_chapters < 250: + max_chapters = 250 + if resolved_benchmark_mode == "longform_250_interactive" and max_chapters < 250: + max_chapters = 250 + if resolved_benchmark_mode == "longform_500" and max_chapters < 500: + max_chapters = 500 + if resolved_benchmark_mode == "longform_500_interactive" and max_chapters < 500: + max_chapters = 500 + if resolved_benchmark_mode == "longform_1000_interactive" and max_chapters < 1000: + max_chapters = 1000 + if resolved_benchmark_mode == "longform_1000_diagnostics" and max_chapters < 1000: + max_chapters = 1000 world_version_overrides = world_version_overrides or {} + authoring = None if simulation_runner is None: from ..services.authoring import AuthoringService authoring = AuthoringService(repository, registry=registry) - simulation_runner = lambda world_id, world_version_id: authoring.run_simulation_for_world_version( # noqa: E731 - world_version_id, - include_cross_pack=False, - max_chapters=max_chapters, - min_end_turn_override=min_end_turn_override, - ) worlds = [] chapter_reports_by_world: Dict[str, List[Dict[str, object]]] = {} pack_payload_by_world: Dict[str, Dict[str, object]] = {} - for world_id in _resolve_world_ids(worldpack): + baseline_reports_by_world: Dict[str, Dict[str, object]] = {} + review_sampling_plans_250: List[Dict[str, object]] = [] + review_sampling_plans_500: List[Dict[str, object]] = [] + review_sampling_plans_1000: List[Dict[str, object]] = [] + requested_world_ids = _resolve_world_ids(worldpack) + fast_gate = _resolve_acceptance_world_ids( + requested_world_ids, + baseline=baseline, + acceptance_profile=acceptance_profile, + changed_worldpacks=changed_worldpacks or [], + fast_gate_weakest_limit=fast_gate_weakest_limit, + ) + selected_world_ids = list(fast_gate.get("selected_world_ids") or requested_world_ids) + progress = _BenchmarkProgressWriter(progress_out) + diagnostic_scan_cache = _DiagnosticIssueScanCache() + progress.emit( + "benchmark_start", + benchmark_mode=resolved_benchmark_mode, + chapter_budget=max_chapters, + requested_world_count=len(requested_world_ids), + selected_world_count=len(selected_world_ids), + progress_out=str(progress_out) if progress_out else "", + checkpoint_out=str(checkpoint_out) if checkpoint_out else "", + ) + for world_id in selected_world_ids: + world_started = perf_counter() + world_stage_timings: Dict[str, float] = {} + progress.emit( + "world_start", + world_id=world_id, + world_index=len(worlds) + 1, + world_count=len(selected_world_ids), + ) + metrics_started = perf_counter() override_world_version_id = world_version_overrides.get(world_id) if override_world_version_id: world_version_id = override_world_version_id @@ -78,13 +1681,103 @@ def run_benchmark( action_policies = dict(pack_payload.get("emotion_action_policies", {})) default_action_policy = next(iter(action_policies.values()), style_pack.get("emotion_actions", {})) action_map = dict(default_action_policy.get("action_map", {})) - report = simulation_runner(world_id, world_version_id) + interactive_scenarios = _resolve_interactive_scenarios( + pack_payload=pack_payload, + target_chapters=max_chapters, + benchmark_mode=resolved_benchmark_mode, + interactive_profile=interactive_profile, + ) + world_stage_timings["metrics_setup"] = _elapsed_ms(metrics_started) + progress.emit_stage( + world_id=world_id, + stage="metrics_setup", + elapsed_ms=world_stage_timings["metrics_setup"], + world_version_id=world_version_id, + ) + def simulation_progress(event: str, **fields: object) -> None: + progress.emit( + f"simulation_{event}", + world_id=world_id, + world_version_id=world_version_id, + **fields, + ) + + simulation_started = perf_counter() + if simulation_runner is None: + if interactive_scenarios: + report = authoring.run_simulation_for_world_version( + world_version_id, + include_cross_pack=False, + max_chapters=max_chapters, + min_end_turn_override=min_end_turn_override, + interactive_scenarios=interactive_scenarios, + longform_setup_override={"authoring_simulation_quality_mode": "benchmark"}, + progress_callback=simulation_progress, + ) + else: + report = authoring.run_simulation_for_world_version( + world_version_id, + include_cross_pack=False, + max_chapters=max_chapters, + min_end_turn_override=min_end_turn_override, + longform_setup_override={"authoring_simulation_quality_mode": "benchmark"}, + progress_callback=simulation_progress, + ) + else: + if interactive_scenarios: + parameters = inspect.signature(simulation_runner).parameters + if len(parameters) >= 3: + report = simulation_runner(world_id, world_version_id, interactive_scenarios) + else: + report = simulation_runner(world_id, world_version_id) + else: + report = simulation_runner(world_id, world_version_id) + world_stage_timings["simulation"] = _elapsed_ms(simulation_started) + progress.emit_stage( + world_id=world_id, + stage="simulation", + elapsed_ms=world_stage_timings["simulation"], + completed_chapters=int(report.get("completed_chapters", 0) or 0), + stop_reason=str(report.get("stop_reason", "")), + ) + baseline_reports_by_world[world_id] = copy.deepcopy(report) + longform_summary = dict(report.get("longform_summary", {})) + longform_gate = dict(report.get("longform_gate") or {}) + interactive_summary = dict(report.get("interactive_summary") or {}) + report_conversion_started = perf_counter() chapter_reports = [EvaluationReport.from_dict(item) for item in report.get("chapter_evaluations", [])] chapter_reports_by_world[world_id] = [item.to_dict() for item in chapter_reports] + world_stage_timings["report_conversion"] = _elapsed_ms(report_conversion_started) + progress.emit_stage( + world_id=world_id, + stage="report_conversion", + elapsed_ms=world_stage_timings["report_conversion"], + chapter_report_count=len(chapter_reports_by_world[world_id]), + ) evaluation = report.get("evaluation_summary", {}) + issue_mix_started = perf_counter() issue_mix = build_issue_mix( - [issue.to_dict() for chapter_report in chapter_reports for issue in chapter_report.issues] + _chapter_surface_issue_payloads( + chapter_reports_by_world[world_id], + target_chapters=max_chapters, + diagnostic_scan_cache=diagnostic_scan_cache, + ) ) + world_stage_timings["issue_mix"] = _elapsed_ms(issue_mix_started) + progress.emit_stage( + world_id=world_id, + stage="issue_mix", + elapsed_ms=world_stage_timings["issue_mix"], + issue_category_count=len(issue_mix), + diagnostic_scan_hits=diagnostic_scan_cache.hits, + diagnostic_scan_misses=diagnostic_scan_cache.misses, + ) + q09_incidence_rate = round( + sum(1 for payload in chapter_reports_by_world[world_id] if any(issue.get("issue_code") == "Q09" for issue in payload.get("issues", []))) + / float(max(1, len(chapter_reports_by_world[world_id]) or 1)), + 3, + ) + route_diagnostics_started = perf_counter() route_diagnostics = build_route_diagnostics( [float(item.scores.overall_score) for item in chapter_reports], completed_chapters=int(report.get("completed_chapters", 0)), @@ -97,6 +1790,13 @@ def run_benchmark( min_end_turn_target=int(report.get("min_end_turn_target", min_end_turn_override or 6)), stop_reason=str(report.get("stop_reason", "chapter_budget_reached")), ) + world_stage_timings["route_diagnostics"] = _elapsed_ms(route_diagnostics_started) + progress.emit_stage( + world_id=world_id, + stage="route_diagnostics", + elapsed_ms=world_stage_timings["route_diagnostics"], + ) + metrics_aggregation_started = perf_counter() if chapter_reports: character_fidelity = sum(item.scores.character_fidelity for item in chapter_reports) / len(chapter_reports) causal_continuity = sum(item.scores.causal_continuity for item in chapter_reports) / len(chapter_reports) @@ -117,8 +1817,20 @@ def run_benchmark( emotion_action_specificity = min(1.0, sum(action_buckets) / float(max(1, len(action_buckets) * 8))) else: emotion_action_specificity = 0.0 + trace_runtime_profile = _runtime_profile_from_chapter_trace(list(report.get("chapter_trace") or [])) + world_stage_timings["generation_runtime"] = float(trace_runtime_profile.get("generation_runtime_ms", 0.0) or 0.0) + world_stage_timings["quality_pass"] = float(trace_runtime_profile.get("quality_pass_ms", 0.0) or 0.0) + world_stage_timings["lint"] = float(trace_runtime_profile.get("lint_ms", 0.0) or 0.0) + world_stage_timings["evaluation"] = float(trace_runtime_profile.get("evaluation_ms", 0.0) or 0.0) + world_stage_timings["metrics_aggregation"] = _elapsed_ms(metrics_aggregation_started) + progress.emit_stage( + world_id=world_id, + stage="metrics_aggregation", + elapsed_ms=world_stage_timings["metrics_aggregation"], + ) world_metrics = { "world_id": world_id, + "world_version_id": world_version_id, "pass_rate": evaluation.get("pass_rate", 0.0), "rewrite_rate": evaluation.get("rewrite_rate", 0.0), "block_rate": evaluation.get("block_rate", 0.0), @@ -134,19 +1846,225 @@ def run_benchmark( "emotion_action_specificity": round(emotion_action_specificity, 3), "cross_pack_pass_rate": evaluation.get("pass_rate", 0.0), "issue_mix": issue_mix, + "surface_issue_chapters": _chapter_surface_issue_diagnostics( + chapter_reports_by_world[world_id], + target_chapters=max_chapters, + diagnostic_scan_cache=diagnostic_scan_cache, + ), "long_route_quality": route_diagnostics["long_route_quality"], "mid_arc_drop": route_diagnostics["mid_arc_drop"], "dialogue_distinctness": round(voice_separation_score, 3), + "character_drift_rate": float(longform_summary.get("character_drift_rate", 0.0) or 0.0), + "promise_unresolved_rate": float(longform_summary.get("promise_unresolved_rate", 0.0) or 0.0), + "arc_task_repeat_rate": float(longform_summary.get("arc_task_repeat_rate", 0.0) or 0.0), + "q09_incidence_rate": float(longform_summary.get("q09_incidence_rate", q09_incidence_rate) or q09_incidence_rate), + "premature_ending_trigger_rate": float(longform_summary.get("premature_ending_trigger_rate", 0.0) or 0.0), + "volume_climax_spacing_error": float(longform_summary.get("volume_climax_spacing_error", 0.0) or 0.0), **long_route_diagnostics, } - world_metrics["top_issue_categories"] = list(evaluation.get("top_issue_categories", [])) + world_metrics["generation_hard_constraint_summary"] = summarize_generation_hard_constraints( + chapter_reports_by_world[world_id], + chapter_trace_payloads=list(report.get("chapter_trace") or []), + ) + world_metrics["runtime_profile"] = { + "schema_version": "benchmark_world_runtime_profile/v1", + "world_id": world_id, + "chapter_count": int(trace_runtime_profile.get("chapter_count", 0) or len(chapter_reports)), + "stages_ms": {key: round(float(value or 0.0), 3) for key, value in sorted(world_stage_timings.items())}, + "render_timing_ms": dict(trace_runtime_profile.get("render_timing_ms") or {}), + "quality_pass_action_count": int(trace_runtime_profile.get("quality_pass_action_count", 0) or 0), + "quality_pass_stage_action_counts": dict(trace_runtime_profile.get("quality_pass_stage_action_counts") or {}), + "quality_pass_stage_estimated_ms": dict(trace_runtime_profile.get("quality_pass_stage_estimated_ms") or {}), + "quality_pass_stage_estimated": bool(trace_runtime_profile.get("quality_pass_stage_estimated", False)), + "diagnostic_issue_scan_cache": diagnostic_scan_cache.summary(), + } + continuation_metrics = repository.aggregate_eval_metrics(world_version_id=world_version_id) + world_metrics["continuation_calibration"] = dict(continuation_metrics.get("q03_q09_calibration") or {}) + if interactive_scenarios: + world_metrics["interactive_summary"] = dict(interactive_summary) + world_metrics["post_steer_issue_window_summary"] = list(report.get("post_steer_issue_window_summary") or []) + world_metrics["interactive_profile"] = interactive_profile or "default" + if resolved_benchmark_mode == "longform_100": + if not longform_gate: + longform_gate = evaluate_longform_gate( + target_chapters=int(longform_summary.get("target_chapters", max_chapters) or max_chapters), + completed_chapters=int(report.get("completed_chapters", 0)), + pass_rate=float(evaluation.get("pass_rate", 0.0) or 0.0), + block_rate=float(evaluation.get("block_rate", 0.0) or 0.0), + stop_reason=str(report.get("stop_reason", "")), + completion_ratio=float(report.get("completion_ratio", long_route_diagnostics.get("completion_ratio", 0.0)) or 0.0), + mid_arc_pass_rate=float(long_route_diagnostics.get("mid_arc_pass_rate", 0.0) or 0.0), + q09_incidence_rate=float(world_metrics.get("q09_incidence_rate", 0.0) or 0.0), + character_drift_rate=float(longform_summary.get("character_drift_rate", 0.0) or 0.0), + promise_unresolved_rate=float(longform_summary.get("promise_unresolved_rate", 0.0) or 0.0), + arc_task_repeat_rate=float(longform_summary.get("arc_task_repeat_rate", 0.0) or 0.0), + premature_ending_trigger_rate=float(longform_summary.get("premature_ending_trigger_rate", 0.0) or 0.0), + volume_climax_spacing_error=float(longform_summary.get("volume_climax_spacing_error", 0.0) or 0.0), + ) + world_metrics["longform_gate"] = dict(longform_gate) + if resolved_benchmark_mode == "longform_100_interactive": + steering_recovery_rate = float(interactive_summary.get("steering_recovery_rate", 0.0) or 0.0) + post_steer_route_survival = float(interactive_summary.get("post_steer_route_survival", 0.0) or 0.0) + memory_consistency_after_steer = float(interactive_summary.get("memory_consistency_after_steer", 0.0) or 0.0) + promise_reconciliation_after_steer = float(interactive_summary.get("promise_reconciliation_after_steer", 0.0) or 0.0) + replan_stability_score = float(interactive_summary.get("replan_stability_score", 0.0) or 0.0) + interactive_gate_checks = { + "steering_recovery_rate": steering_recovery_rate >= float(INTERACTIVE_LONGFORM_THRESHOLDS["steering_recovery_rate_min"]), + "post_steer_route_survival": post_steer_route_survival >= float(INTERACTIVE_LONGFORM_THRESHOLDS["post_steer_route_survival_min"]), + "memory_consistency_after_steer": memory_consistency_after_steer >= float(INTERACTIVE_LONGFORM_THRESHOLDS["memory_consistency_after_steer_min"]), + "promise_reconciliation_after_steer": promise_reconciliation_after_steer >= float(INTERACTIVE_LONGFORM_THRESHOLDS["promise_reconciliation_after_steer_min"]), + "replan_stability_score": replan_stability_score >= float(INTERACTIVE_LONGFORM_THRESHOLDS["replan_stability_score_min"]), + } + world_metrics["interactive_summary"] = dict(interactive_summary) + world_metrics["interactive_longform_gate"] = { + "passed": bool(longform_gate.get("passed")) and all(interactive_gate_checks.values()), + "failed_checks": [name for name, passed in interactive_gate_checks.items() if not passed] + ([] if longform_gate.get("passed") else ["longform_gate"]), + "checks": interactive_gate_checks, + } + if resolved_benchmark_mode in {"longform_250", "longform_250_interactive"}: + longform_250_summary = dict(report.get("longform_250_summary") or {}) + failed_checks = list(dict(report.get("longform_250_evidence") or {}).get("failed_checks", [])) + world_metrics["longform_250_summary"] = longform_250_summary + world_metrics["longform_250_gate"] = { + "passed": not failed_checks, + "failed_checks": failed_checks, + } + world_metrics["review_sampling_plan_250"] = _review_sampling_plan_250(report, world_id=world_id, world_version_id=world_version_id) + review_sampling_plans_250.extend(list(world_metrics["review_sampling_plan_250"])) + if resolved_benchmark_mode in {"longform_500", "longform_500_interactive"}: + longform_500_summary = dict(report.get("longform_500_summary") or {}) + failed_checks = list(dict(report.get("longform_500_evidence") or {}).get("failed_checks", [])) + world_metrics["longform_500_summary"] = longform_500_summary + world_metrics["longform_500_gate"] = { + "passed": not failed_checks, + "failed_checks": failed_checks, + } + world_metrics["review_sampling_plan_500"] = _review_sampling_plan_500(report, world_id=world_id, world_version_id=world_version_id) + review_sampling_plans_500.extend(list(world_metrics["review_sampling_plan_500"])) + if resolved_benchmark_mode in {"longform_1000_diagnostics", "longform_1000_interactive"}: + longform_1000_summary = dict(report.get("longform_1000_summary") or {}) + failed_checks = list(dict(report.get("longform_1000_evidence") or {}).get("failed_checks", [])) + world_metrics["longform_1000_summary"] = longform_1000_summary + world_metrics["longform_1000_feasibility"] = { + "passed": not failed_checks, + "failed_checks": failed_checks, + } + world_metrics["review_sampling_plan_1000"] = _review_sampling_plan_1000( + report, + world_id=world_id, + world_version_id=world_version_id, + ) + review_sampling_plans_1000.extend(list(world_metrics["review_sampling_plan_1000"])) + world_metrics["character_fidelity_remediation_framework"] = dict( + report.get("character_fidelity_remediation_framework") or {} + ) + if resolved_benchmark_mode == "longform_1000_interactive": + steering_recovery_rate = float(interactive_summary.get("steering_recovery_rate", 0.0) or 0.0) + post_steer_route_survival = float(interactive_summary.get("post_steer_route_survival", 0.0) or 0.0) + memory_consistency_after_steer = float(interactive_summary.get("memory_consistency_after_steer", 0.0) or 0.0) + promise_reconciliation_after_steer = float(interactive_summary.get("promise_reconciliation_after_steer", 0.0) or 0.0) + replan_stability_score = float(interactive_summary.get("replan_stability_score", 0.0) or 0.0) + interactive_gate_checks = { + "steering_recovery_rate": steering_recovery_rate >= float(INTERACTIVE_LONGFORM_1000_THRESHOLDS["steering_recovery_rate_min"]), + "post_steer_route_survival": post_steer_route_survival >= float(INTERACTIVE_LONGFORM_1000_THRESHOLDS["post_steer_route_survival_min"]), + "memory_consistency_after_steer": memory_consistency_after_steer >= float(INTERACTIVE_LONGFORM_1000_THRESHOLDS["memory_consistency_after_steer_min"]), + "promise_reconciliation_after_steer": promise_reconciliation_after_steer >= float(INTERACTIVE_LONGFORM_1000_THRESHOLDS["promise_reconciliation_after_steer_min"]), + "replan_stability_score": replan_stability_score >= float(INTERACTIVE_LONGFORM_1000_THRESHOLDS["replan_stability_score_min"]), + } + world_metrics["interactive_summary"] = dict(interactive_summary) + world_metrics["interactive_longform_1000_gate"] = { + "passed": bool((world_metrics.get("longform_1000_feasibility") or {}).get("passed")) and all(interactive_gate_checks.values()), + "failed_checks": [name for name, passed in interactive_gate_checks.items() if not passed] + ([] if (world_metrics.get("longform_1000_feasibility") or {}).get("passed") else ["longform_1000_feasibility"]), + "checks": interactive_gate_checks, + } + if resolved_benchmark_mode == "longform_500_interactive": + steering_recovery_rate = float(interactive_summary.get("steering_recovery_rate", 0.0) or 0.0) + post_steer_route_survival = float(interactive_summary.get("post_steer_route_survival", 0.0) or 0.0) + memory_consistency_after_steer = float(interactive_summary.get("memory_consistency_after_steer", 0.0) or 0.0) + promise_reconciliation_after_steer = float(interactive_summary.get("promise_reconciliation_after_steer", 0.0) or 0.0) + replan_stability_score = float(interactive_summary.get("replan_stability_score", 0.0) or 0.0) + interactive_gate_checks = { + "steering_recovery_rate": steering_recovery_rate >= float(INTERACTIVE_LONGFORM_500_THRESHOLDS["steering_recovery_rate_min"]), + "post_steer_route_survival": post_steer_route_survival >= float(INTERACTIVE_LONGFORM_500_THRESHOLDS["post_steer_route_survival_min"]), + "memory_consistency_after_steer": memory_consistency_after_steer >= float(INTERACTIVE_LONGFORM_500_THRESHOLDS["memory_consistency_after_steer_min"]), + "promise_reconciliation_after_steer": promise_reconciliation_after_steer >= float(INTERACTIVE_LONGFORM_500_THRESHOLDS["promise_reconciliation_after_steer_min"]), + "replan_stability_score": replan_stability_score >= float(INTERACTIVE_LONGFORM_500_THRESHOLDS["replan_stability_score_min"]), + } + world_metrics["interactive_summary"] = dict(interactive_summary) + world_metrics["interactive_longform_500_gate"] = { + "passed": bool((world_metrics.get("longform_500_gate") or {}).get("passed")) and all(interactive_gate_checks.values()), + "failed_checks": [name for name, passed in interactive_gate_checks.items() if not passed] + ([] if (world_metrics.get("longform_500_gate") or {}).get("passed") else ["longform_500_gate"]), + "checks": interactive_gate_checks, + } + if resolved_benchmark_mode == "longform_250_interactive": + steering_recovery_rate = float(interactive_summary.get("steering_recovery_rate", 0.0) or 0.0) + post_steer_route_survival = float(interactive_summary.get("post_steer_route_survival", 0.0) or 0.0) + memory_consistency_after_steer = float(interactive_summary.get("memory_consistency_after_steer", 0.0) or 0.0) + promise_reconciliation_after_steer = float(interactive_summary.get("promise_reconciliation_after_steer", 0.0) or 0.0) + replan_stability_score = float(interactive_summary.get("replan_stability_score", 0.0) or 0.0) + interactive_gate_checks = { + "steering_recovery_rate": steering_recovery_rate >= float(INTERACTIVE_LONGFORM_250_THRESHOLDS["steering_recovery_rate_min"]), + "post_steer_route_survival": post_steer_route_survival >= float(INTERACTIVE_LONGFORM_250_THRESHOLDS["post_steer_route_survival_min"]), + "memory_consistency_after_steer": memory_consistency_after_steer >= float(INTERACTIVE_LONGFORM_250_THRESHOLDS["memory_consistency_after_steer_min"]), + "promise_reconciliation_after_steer": promise_reconciliation_after_steer >= float(INTERACTIVE_LONGFORM_250_THRESHOLDS["promise_reconciliation_after_steer_min"]), + "replan_stability_score": replan_stability_score >= float(INTERACTIVE_LONGFORM_250_THRESHOLDS["replan_stability_score_min"]), + } + world_metrics["interactive_summary"] = dict(interactive_summary) + world_metrics["interactive_longform_250_gate"] = { + "passed": bool((world_metrics.get("longform_250_gate") or {}).get("passed")) and all(interactive_gate_checks.values()), + "failed_checks": [name for name, passed in interactive_gate_checks.items() if not passed] + ([] if (world_metrics.get("longform_250_gate") or {}).get("passed") else ["longform_250_gate"]), + "checks": interactive_gate_checks, + } + world_metrics["top_issue_categories"] = [ + { + "issue_code": issue.get("issue_code"), + "count": int(issue.get("count", 0)), + "owning_module": issue.get("owning_module", ""), + "fix_hint": issue.get("fix_hint", ""), + } + for issue in issue_mix + ] world_metrics["dimension_scores"] = build_dimension_scores(world_metrics) world_metrics["issue_summary"] = build_issue_summary( top_issue_categories=world_metrics["top_issue_categories"], dimension_scores=world_metrics["dimension_scores"], route_longevity_target=max_chapters, ) + world_metrics["content_quality_contract_coverage"] = asset_quality_contract_coverage(pack_payload) + content_quality_contract_started = perf_counter() + world_metrics["content_quality_contract_window_metrics"] = content_quality_window_metrics( + chapter_report_payloads=chapter_reports_by_world[world_id], + world_metrics=world_metrics, + diagnostic_issue_code_resolver=diagnostic_scan_cache.codes_for, + ) + world_metrics["runtime_profile"]["stages_ms"]["content_quality_contract"] = _elapsed_ms(content_quality_contract_started) + progress.emit_stage( + world_id=world_id, + stage="content_quality_contract", + elapsed_ms=world_metrics["runtime_profile"]["stages_ms"]["content_quality_contract"], + ) + world_metrics["runtime_profile"]["stages_ms"]["world_total"] = _elapsed_ms(world_started) worlds.append(world_metrics) + _write_benchmark_checkpoint( + checkpoint_out, + benchmark_mode=resolved_benchmark_mode, + chapter_budget=max_chapters, + worlds=worlds, + diagnostic_scan_cache=diagnostic_scan_cache, + stage="world_complete", + ) + progress.emit( + "world_complete", + world_id=world_id, + completed_world_count=len(worlds), + world_count=len(selected_world_ids), + completed_chapters=int(world_metrics.get("route_longevity", 0) or 0), + pass_rate=round(float(world_metrics.get("pass_rate", 0.0) or 0.0), 3), + block_rate=round(float(world_metrics.get("block_rate", 0.0) or 0.0), 3), + world_total_ms=world_metrics["runtime_profile"]["stages_ms"]["world_total"], + ) + post_world_summary_started = perf_counter() + progress.emit("post_world_summary_start", completed_world_count=len(worlds)) worlds = assign_diagnostic_ranks(worlds) cross_pack_pass_rate = sum(item["pass_rate"] for item in worlds) / float(max(1, len(worlds))) strongest_packs = rank_strongest_packs(worlds) @@ -160,21 +2078,396 @@ def run_benchmark( for pack in weakest_packs ] summary = { + "generated_at": datetime.now(timezone.utc).isoformat(), "golden_dir": str(golden_dir), - "benchmark_mode": "long_route" if max_chapters > 6 else "standard", + "benchmark_mode": resolved_benchmark_mode, + "acceptance_profile": acceptance_profile, + "requested_benchmark_world_ids": requested_world_ids, + "benchmark_world_ids": selected_world_ids, + "benchmark_world_count": len(selected_world_ids), + "benchmark_scope_complete": set(selected_world_ids) == set(BENCHMARK_PACKS), + "fast_gate": fast_gate, "chapter_budget": max_chapters, "min_end_turn_override": min_end_turn_override, + "interactive_profile": interactive_profile, "worlds": worlds, "cross_pack_pass_rate": round(cross_pack_pass_rate, 3), "strongest_packs": strongest_packs, "weakest_packs": weakest_packs, "top_failing_packs": rank_top_failing_packs(worlds), "weakest_pack_diagnostics": weakest_pack_diagnostics, + "weakest_pack_polish_program": build_weakest_pack_polish_program(weakest_pack_diagnostics), + "strategy_validation_summary": build_strategy_validation_summary(weakest_pack_diagnostics), } + resolved_strategy_bundle_id = ( + ( + str(strategy_bundle_id or "").strip() + if not validate_strategy_bundle + else ( + str(strategy_bundle_id or "").strip() + or str( + dict((summary.get("strategy_validation_summary") or {}).get("bundle_groups", [{}])[0]).get("strategy_bundle_id") or "" + ).strip() + ) + ) + ) + if validate_strategy_bundle and resolved_strategy_bundle_id: + summary["strategy_bundle_batch_validation"] = _validate_strategy_bundle_batch( + repository=repository, + registry=registry, + weakest_packs=weakest_packs, + weakest_pack_diagnostics=weakest_pack_diagnostics, + strategy_validation_summary=dict(summary.get("strategy_validation_summary") or {}), + strategy_bundle_id=resolved_strategy_bundle_id, + benchmark_mode=resolved_benchmark_mode, + max_chapters=max_chapters, + weakest_limit=weakest_limit, + min_end_turn_override=min_end_turn_override, + interactive_profile=interactive_profile, + pack_payload_by_world=pack_payload_by_world, + baseline_reports_by_world=baseline_reports_by_world, + ) + record_strategy_bundle_batch_validation_run( + repository=repository, + batch_validation=dict(summary.get("strategy_bundle_batch_validation") or {}), + ) + elif resolved_strategy_bundle_id: + bundle_group = next( + ( + dict(item or {}) + for item in list((summary.get("strategy_validation_summary") or {}).get("bundle_groups") or []) + if str((item or {}).get("strategy_bundle_id") or "") == resolved_strategy_bundle_id + ), + {}, + ) + summary["strategy_bundle_batch_validation"] = { + "available": False, + "strategy_bundle_id": resolved_strategy_bundle_id, + "strategy_bundle_label": str(bundle_group.get("strategy_bundle_label") or resolved_strategy_bundle_id), + "batch_execution_mode": "ephemeral_copy", + "benchmark_mode": resolved_benchmark_mode, + "chapter_budget": max_chapters, + "weakest_source_world_ids": [str(item.get("world_id") or "") for item in weakest_packs[: max(1, int(weakest_limit or 3))] if str(item.get("world_id") or "")], + "compatible_world_ids": [], + "skipped_worlds": [], + "validated_world_count": 0, + "validated_worlds": [], + "aggregated_step_receipts": {}, + "aggregated_result_attribution": {}, + "effectiveness_rate": 0.0, + "decision": "", + "decision_reason": "history_only_query", + "adaptation_targets": [], + } + if resolved_strategy_bundle_id: + summary["strategy_bundle_batch_validation_history"] = list_strategy_bundle_batch_validation_history( + repository=repository, + strategy_bundle_id=resolved_strategy_bundle_id, + limit=5, + ) + summary["strategy_bundle_batch_validation_trend"] = build_strategy_bundle_batch_validation_trend( + dict(summary.get("strategy_bundle_batch_validation_history") or {}) + ) if max_chapters > 6: summary["long_route_summary"] = build_long_route_summary(worlds) + summary["content_quality_contract_summary"] = build_content_quality_contract_summary(worlds) + summary["generation_hard_constraint_summary"] = aggregate_generation_hard_constraint_summaries(worlds) + if resolved_benchmark_mode == "long_route" and any(item.get("interactive_summary") for item in worlds): + summary["interactive_long_route_summary"] = build_interactive_long_route_summary( + worlds, + target_chapters=max_chapters, + interactive_profile=interactive_profile or "default", + ) + if resolved_benchmark_mode == "longform_100": + gate_payloads = [dict(item.get("longform_gate") or {}) for item in worlds] + gate_failed_worlds = [item["world_id"] for item in worlds if not dict(item.get("longform_gate") or {}).get("passed")] + summary["longform_summary"] = { + "target_chapters": 100, + "character_drift_rate": round(sum(item.get("character_drift_rate", 0.0) for item in worlds) / float(max(1, len(worlds))), 3), + "promise_unresolved_rate": round(sum(item.get("promise_unresolved_rate", 0.0) for item in worlds) / float(max(1, len(worlds))), 3), + "arc_task_repeat_rate": round(sum(item.get("arc_task_repeat_rate", 0.0) for item in worlds) / float(max(1, len(worlds))), 3), + "q09_incidence_rate": round(sum(item.get("q09_incidence_rate", 0.0) for item in worlds) / float(max(1, len(worlds))), 3), + "premature_ending_trigger_rate": round(sum(item.get("premature_ending_trigger_rate", 0.0) for item in worlds) / float(max(1, len(worlds))), 3), + "volume_climax_spacing_error": round(sum(item.get("volume_climax_spacing_error", 0.0) for item in worlds) / float(max(1, len(worlds))), 3), + "gate_pass_rate": round( + sum(1.0 for payload in gate_payloads if payload.get("passed")) / float(max(1, len(gate_payloads))), + 3, + ), + "failed_worlds": gate_failed_worlds, + } + summary["longform_gate"] = { + "mode": "longform_100", + "passed_world_count": sum(1 for payload in gate_payloads if payload.get("passed")), + "failed_world_count": sum(1 for payload in gate_payloads if not payload.get("passed")), + "pass_rate": round( + sum(1.0 for payload in gate_payloads if payload.get("passed")) / float(max(1, len(gate_payloads))), + 3, + ), + "failed_worlds": gate_failed_worlds, + "calibration": calibrate_longform_thresholds(worlds), + } + if resolved_benchmark_mode == "longform_100_interactive": + interactive_gate_payloads = [dict(item.get("interactive_longform_gate") or {}) for item in worlds] + interactive_failed_worlds = [item["world_id"] for item in worlds if not dict(item.get("interactive_longform_gate") or {}).get("passed")] + summary["interactive_longform_summary"] = { + "target_chapters": 100, + "steering_recovery_rate": round(sum(float((item.get("interactive_summary") or {}).get("steering_recovery_rate", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "post_steer_route_survival": round(sum(float((item.get("interactive_summary") or {}).get("post_steer_route_survival", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "memory_consistency_after_steer": round(sum(float((item.get("interactive_summary") or {}).get("memory_consistency_after_steer", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "promise_reconciliation_after_steer": round(sum(float((item.get("interactive_summary") or {}).get("promise_reconciliation_after_steer", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "replan_stability_score": round(sum(float((item.get("interactive_summary") or {}).get("replan_stability_score", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "gate_pass_rate": round(sum(1.0 for payload in interactive_gate_payloads if payload.get("passed")) / float(max(1, len(interactive_gate_payloads))), 3), + "failed_worlds": interactive_failed_worlds, + } + summary["interactive_longform_gate"] = { + "mode": "longform_100_interactive", + "passed_world_count": sum(1 for payload in interactive_gate_payloads if payload.get("passed")), + "failed_world_count": sum(1 for payload in interactive_gate_payloads if not payload.get("passed")), + "pass_rate": round(sum(1.0 for payload in interactive_gate_payloads if payload.get("passed")) / float(max(1, len(interactive_gate_payloads))), 3), + "failed_worlds": interactive_failed_worlds, + "calibrated_thresholds": dict(INTERACTIVE_LONGFORM_THRESHOLDS), + } + if resolved_benchmark_mode in {"longform_250", "longform_250_interactive"}: + gate_payloads = [dict(item.get("longform_250_gate") or {}) for item in worlds] + failed_worlds = [item["world_id"] for item in worlds if not dict(item.get("longform_250_gate") or {}).get("passed")] + execution_summary = ( + _execute_review_sampling_plan_250( + training_signal=training_signal, + review_sampling_plans_250=review_sampling_plans_250, + chapter_reports_by_world=chapter_reports_by_world, + ) + if execute_review_sampling_250 + else {} + ) + review_sample_coverage_250 = _build_review_sample_coverage_250( + training_signal=training_signal, + review_sampling_plans_250=review_sampling_plans_250, + execution_summary=execution_summary, + ) + summary["longform_250_summary"] = { + "target_chapters": 250, + "volume_boundary_survival": round(sum(float((item.get("longform_250_summary") or {}).get("volume_boundary_survival", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "memory_recall_coverage": round(sum(float((item.get("longform_250_summary") or {}).get("memory_recall_coverage", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "replan_stability_score": round(sum(float((item.get("longform_250_summary") or {}).get("replan_stability_score", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "volume_snapshot_integrity": round(sum(float((item.get("longform_250_summary") or {}).get("volume_snapshot_integrity", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "mid_volume_pass_rate": round(sum(float((item.get("longform_250_summary") or {}).get("mid_volume_pass_rate", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "late_volume_pass_rate": round(sum(float((item.get("longform_250_summary") or {}).get("late_volume_pass_rate", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "gate_pass_rate": round(sum(1.0 for payload in gate_payloads if payload.get("passed")) / float(max(1, len(gate_payloads))), 3), + "failed_worlds": failed_worlds, + } + summary["longform_250_evidence"] = { + "gate_pass_rate": summary["longform_250_summary"]["gate_pass_rate"], + "failed_worlds": failed_worlds, + "review_sample_coverage_250": review_sample_coverage_250, + "review_sample_closeout_ready": bool(review_sample_coverage_250.get("closeout_ready", False)), + "review_sample_human_closeout_ready": bool(review_sample_coverage_250.get("human_closeout_ready", False)), + } + summary["review_sample_coverage_250"] = review_sample_coverage_250 + if resolved_benchmark_mode == "longform_250_interactive": + interactive_gate_payloads = [dict(item.get("interactive_longform_250_gate") or {}) for item in worlds] + interactive_failed_worlds = [item["world_id"] for item in worlds if not dict(item.get("interactive_longform_250_gate") or {}).get("passed")] + summary["longform_250_interactive_summary"] = { + "target_chapters": 250, + "steering_recovery_rate": round(sum(float((item.get("interactive_summary") or {}).get("steering_recovery_rate", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "post_steer_route_survival": round(sum(float((item.get("interactive_summary") or {}).get("post_steer_route_survival", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "memory_consistency_after_steer": round(sum(float((item.get("interactive_summary") or {}).get("memory_consistency_after_steer", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "promise_reconciliation_after_steer": round(sum(float((item.get("interactive_summary") or {}).get("promise_reconciliation_after_steer", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "replan_stability_score": round(sum(float((item.get("interactive_summary") or {}).get("replan_stability_score", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "gate_pass_rate": round(sum(1.0 for payload in interactive_gate_payloads if payload.get("passed")) / float(max(1, len(interactive_gate_payloads))), 3), + "failed_worlds": interactive_failed_worlds, + } + summary["longform_250_interactive_gate"] = { + "mode": "longform_250_interactive", + "passed_world_count": sum(1 for payload in interactive_gate_payloads if payload.get("passed")), + "failed_world_count": sum(1 for payload in interactive_gate_payloads if not payload.get("passed")), + "pass_rate": round(sum(1.0 for payload in interactive_gate_payloads if payload.get("passed")) / float(max(1, len(interactive_gate_payloads))), 3), + "failed_worlds": interactive_failed_worlds, + "calibrated_thresholds": dict(INTERACTIVE_LONGFORM_250_THRESHOLDS), + } + if resolved_benchmark_mode in {"longform_500", "longform_500_interactive"}: + gate_payloads = [dict(item.get("longform_500_gate") or {}) for item in worlds] + failed_worlds = [item["world_id"] for item in worlds if not dict(item.get("longform_500_gate") or {}).get("passed")] + execution_summary = ( + _execute_review_sampling_plan_500( + training_signal=training_signal, + review_sampling_plans_500=review_sampling_plans_500, + chapter_reports_by_world=chapter_reports_by_world, + ) + if execute_review_sampling_500 + else {} + ) + human_execution_summary = ( + _execute_human_review_closeout_plan_500( + training_signal=training_signal, + review_sampling_plans_500=review_sampling_plans_500, + chapter_reports_by_world=chapter_reports_by_world, + reviewer_id=human_review_closeout_500_reviewer_id, + target_chapters=max_chapters, + diagnostic_scan_cache=diagnostic_scan_cache, + ) + if execute_human_review_closeout_500 + else {} + ) + review_sample_coverage_500 = _build_review_sample_coverage_500( + training_signal=training_signal, + review_sampling_plans_500=review_sampling_plans_500, + execution_summary=execution_summary, + human_execution_summary=human_execution_summary, + ) + summary["longform_500_summary"] = { + "target_chapters": 500, + "series_boundary_survival": round(sum(float((item.get("longform_500_summary") or {}).get("series_boundary_survival", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "series_memory_snapshot_integrity": round(sum(float((item.get("longform_500_summary") or {}).get("series_memory_snapshot_integrity", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "memory_recall_coverage": round(sum(float((item.get("longform_500_summary") or {}).get("memory_recall_coverage", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "replan_stability_score": round(sum(float((item.get("longform_500_summary") or {}).get("replan_stability_score", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "late_series_pass_rate": round(sum(float((item.get("longform_500_summary") or {}).get("late_series_pass_rate", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "series_ending_control_score": round(sum(float((item.get("longform_500_summary") or {}).get("series_ending_control_score", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "gate_pass_rate": round(sum(1.0 for payload in gate_payloads if payload.get("passed")) / float(max(1, len(gate_payloads))), 3), + "failed_worlds": failed_worlds, + } + summary["longform_500_evidence"] = { + "gate_pass_rate": summary["longform_500_summary"]["gate_pass_rate"], + "failed_worlds": failed_worlds, + "review_sample_coverage_500": review_sample_coverage_500, + "review_sample_human_closeout_ready": bool(review_sample_coverage_500.get("human_closeout_ready", False)), + "ending_window_human_closeout_ready": bool(review_sample_coverage_500.get("ending_window_human_closeout_ready", False)), + } + summary["review_sample_coverage_500"] = review_sample_coverage_500 + if resolved_benchmark_mode == "longform_500_interactive": + interactive_gate_payloads = [dict(item.get("interactive_longform_500_gate") or {}) for item in worlds] + interactive_failed_worlds = [item["world_id"] for item in worlds if not dict(item.get("interactive_longform_500_gate") or {}).get("passed")] + summary["longform_500_interactive_summary"] = { + "target_chapters": 500, + "steering_recovery_rate": round(sum(float((item.get("interactive_summary") or {}).get("steering_recovery_rate", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "post_steer_route_survival": round(sum(float((item.get("interactive_summary") or {}).get("post_steer_route_survival", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "memory_consistency_after_steer": round(sum(float((item.get("interactive_summary") or {}).get("memory_consistency_after_steer", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "promise_reconciliation_after_steer": round(sum(float((item.get("interactive_summary") or {}).get("promise_reconciliation_after_steer", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "replan_stability_score": round(sum(float((item.get("interactive_summary") or {}).get("replan_stability_score", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "gate_pass_rate": round(sum(1.0 for payload in interactive_gate_payloads if payload.get("passed")) / float(max(1, len(interactive_gate_payloads))), 3), + "failed_worlds": interactive_failed_worlds, + } + summary["longform_500_interactive_gate"] = { + "mode": "longform_500_interactive", + "passed_world_count": sum(1 for payload in interactive_gate_payloads if payload.get("passed")), + "failed_world_count": sum(1 for payload in interactive_gate_payloads if not payload.get("passed")), + "pass_rate": round(sum(1.0 for payload in interactive_gate_payloads if payload.get("passed")) / float(max(1, len(interactive_gate_payloads))), 3), + "failed_worlds": interactive_failed_worlds, + "calibrated_thresholds": dict(INTERACTIVE_LONGFORM_500_THRESHOLDS), + } + if resolved_benchmark_mode in {"longform_1000_diagnostics", "longform_1000_interactive"}: + feasibility_payloads = [dict(item.get("longform_1000_feasibility") or {}) for item in worlds] + failed_worlds = [item["world_id"] for item in worlds if not dict(item.get("longform_1000_feasibility") or {}).get("passed")] + summary["longform_1000_summary"] = { + "target_chapters": 1000, + "series_boundary_survival": round(sum(float((item.get("longform_1000_summary") or {}).get("series_boundary_survival", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "series_memory_snapshot_integrity": round(sum(float((item.get("longform_1000_summary") or {}).get("series_memory_snapshot_integrity", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "series_snapshot_count": round(sum(float((item.get("longform_1000_summary") or {}).get("series_snapshot_count", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "retained_series_snapshot_target": round(sum(float((item.get("longform_1000_summary") or {}).get("retained_series_snapshot_target", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "memory_recall_coverage": round(sum(float((item.get("longform_1000_summary") or {}).get("memory_recall_coverage", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "replan_stability_score": round(sum(float((item.get("longform_1000_summary") or {}).get("replan_stability_score", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "archive_retention_integrity": round(sum(float((item.get("longform_1000_summary") or {}).get("archive_retention_integrity", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "timeline_retention_integrity": round(sum(float((item.get("longform_1000_summary") or {}).get("timeline_retention_integrity", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "continuation_state_retention_integrity": round(sum(float((item.get("longform_1000_summary") or {}).get("continuation_state_retention_integrity", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "late_stage_runtime_p95_ms": round(sum(float((item.get("longform_1000_summary") or {}).get("late_stage_runtime_p95_ms", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "late_stage_runtime_budget_score": round(sum(float((item.get("longform_1000_summary") or {}).get("late_stage_runtime_budget_score", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "series_ending_control_score": round(sum(float((item.get("longform_1000_summary") or {}).get("series_ending_control_score", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "diagnostic_pass_rate": round(sum(1.0 for payload in feasibility_payloads if payload.get("passed")) / float(max(1, len(feasibility_payloads))), 3), + "failed_worlds": failed_worlds, + } + summary["longform_1000_evidence"] = { + "diagnostic_pass_rate": summary["longform_1000_summary"]["diagnostic_pass_rate"], + "failed_worlds": failed_worlds, + } + summary["review_sample_coverage_1000"] = _build_review_sample_coverage_1000( + training_signal=training_signal, + review_sampling_plans_1000=review_sampling_plans_1000, + ) + summary["character_fidelity_remediation_framework"] = { + "available": True, + "world_count": len(worlds), + "q06_worlds": [ + { + "world_id": item["world_id"], + "character_fidelity": float(item.get("character_fidelity", 0.0) or 0.0), + "q06_issue_share": next( + (float(issue.get("share", 0.0) or 0.0) for issue in item.get("issue_mix", []) if issue.get("issue_code") == "Q06"), + 0.0, + ), + "framework": dict(item.get("character_fidelity_remediation_framework") or {}), + } + for item in worlds + if next((issue for issue in item.get("issue_mix", []) if issue.get("issue_code") == "Q06"), None) + or float(item.get("character_fidelity", 0.0) or 0.0) < 0.34 + ], + "recommended_assets": [ + "characters", + "emotion_action_policies", + "scene_blueprints", + ], + } + if resolved_benchmark_mode == "longform_1000_interactive": + interactive_gate_payloads = [dict(item.get("interactive_longform_1000_gate") or {}) for item in worlds] + interactive_failed_worlds = [item["world_id"] for item in worlds if not dict(item.get("interactive_longform_1000_gate") or {}).get("passed")] + summary["longform_1000_interactive_summary"] = { + "target_chapters": 1000, + "steering_recovery_rate": round(sum(float((item.get("interactive_summary") or {}).get("steering_recovery_rate", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "post_steer_route_survival": round(sum(float((item.get("interactive_summary") or {}).get("post_steer_route_survival", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "memory_consistency_after_steer": round(sum(float((item.get("interactive_summary") or {}).get("memory_consistency_after_steer", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "promise_reconciliation_after_steer": round(sum(float((item.get("interactive_summary") or {}).get("promise_reconciliation_after_steer", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "replan_stability_score": round(sum(float((item.get("interactive_summary") or {}).get("replan_stability_score", 0.0)) for item in worlds) / float(max(1, len(worlds))), 3), + "gate_pass_rate": round(sum(1.0 for payload in interactive_gate_payloads if payload.get("passed")) / float(max(1, len(interactive_gate_payloads))), 3), + "failed_worlds": interactive_failed_worlds, + } + summary["longform_1000_interactive_gate"] = { + "mode": "longform_1000_interactive", + "passed_world_count": sum(1 for payload in interactive_gate_payloads if payload.get("passed")), + "failed_world_count": sum(1 for payload in interactive_gate_payloads if not payload.get("passed")), + "pass_rate": round(sum(1.0 for payload in interactive_gate_payloads if payload.get("passed")) / float(max(1, len(interactive_gate_payloads))), 3), + "failed_worlds": interactive_failed_worlds, + "calibrated_thresholds": dict(INTERACTIVE_LONGFORM_1000_THRESHOLDS), + } + summary["longform_l1_signoff"] = build_longform_l1_signoff(summary) + summary["interactive_longform_signoff"] = build_interactive_longform_signoff(summary) + summary["longform_250_signoff"] = build_longform_250_signoff(summary) + summary["longform_250_interactive_signoff"] = build_longform_250_interactive_signoff(summary) + summary["longform_250_human_review_closeout"] = build_longform_250_human_review_closeout(summary) + summary["longform_500_signoff"] = build_longform_500_signoff(summary) + summary["longform_500_human_review_closeout"] = build_longform_500_human_review_closeout(summary) + summary["longform_500_ending_signoff"] = build_longform_500_ending_signoff(summary) + summary["longform_500_interactive_signoff"] = build_longform_500_interactive_signoff(summary) + summary["longform_1000_readiness"] = build_longform_1000_readiness(summary) + summary["longform_1000_human_review_closeout"] = build_longform_1000_human_review_closeout(summary) + summary["longform_1000_interactive_signoff"] = build_longform_1000_interactive_signoff(summary) + summary["longform_1000_feasibility"] = build_longform_1000_feasibility(summary) + summary["content_quality_contract_gate"] = evaluate_content_quality_contract_gate(summary) if baseline: summary["delta_summary"] = benchmark_delta_report(summary, baseline) + summary["commercial_long_route_gate"] = evaluate_commercial_long_route_gate(summary) + summary["phase_a_quality_gate"] = evaluate_release_quality_gate(summary) + summary["benchmark_runtime_profile"] = _build_benchmark_runtime_profile( + worlds=worlds, + total_wall_ms=_elapsed_ms(benchmark_started), + acceptance_profile=acceptance_profile, + fast_gate=fast_gate, + post_world_summary_ms=_elapsed_ms(post_world_summary_started), + diagnostic_issue_scan_cache=diagnostic_scan_cache.summary(), + ) + _write_benchmark_checkpoint( + checkpoint_out, + benchmark_mode=resolved_benchmark_mode, + chapter_budget=max_chapters, + worlds=worlds, + diagnostic_scan_cache=diagnostic_scan_cache, + stage="complete", + ) + progress.emit( + "benchmark_complete", + completed_world_count=len(worlds), + total_wall_ms=summary["benchmark_runtime_profile"]["total_wall_ms"], + diagnostic_scan_hits=diagnostic_scan_cache.hits, + diagnostic_scan_misses=diagnostic_scan_cache.misses, + slow_scan_count=len(diagnostic_scan_cache.slow_scans), + ) return summary @@ -185,25 +2478,74 @@ def main(argv: Iterable[str] | None = None) -> int: parser.add_argument("--database-url", default=None) parser.add_argument("--baseline-file", default="tests/benchmark_baseline.json") parser.add_argument("--markdown-out", default=None) + parser.add_argument("--benchmark-mode", default=None) parser.add_argument("--max-chapters", type=int, default=6) parser.add_argument("--min-end-turn-override", type=int, default=None) + parser.add_argument("--execute-review-sampling-250", action="store_true") + parser.add_argument("--execute-review-sampling-500", action="store_true") + parser.add_argument("--execute-human-review-closeout-500", action="store_true") + parser.add_argument("--human-review-closeout-500-reviewer-id", default="ops_longform500_reviewer_after_residual_fix") + parser.add_argument("--interactive-profile", choices=["default", "strong"], default=None) + parser.add_argument("--acceptance-profile", choices=["full", "nightly", "fast"], default="full") + parser.add_argument("--changed-worldpacks", default="") + parser.add_argument("--fast-gate-weakest-limit", type=int, default=3) + parser.add_argument("--runtime-profile-out", default=None) + parser.add_argument("--progress-out", default=None) + parser.add_argument("--checkpoint-out", default=None) args = parser.parse_args(list(argv) if argv is not None else None) baseline_path = Path(args.baseline_file) baseline = json.loads(baseline_path.read_text(encoding="utf-8")) if baseline_path.exists() else None repository = SQLAlchemyRepository(database_url=args.database_url) + progress_out = Path(args.progress_out) if args.progress_out else None + checkpoint_out = ( + Path(args.checkpoint_out) + if args.checkpoint_out + else (progress_out.with_suffix(".checkpoint.json") if progress_out else None) + ) summary = run_benchmark( repository=repository, golden_dir=Path(args.golden_dir), worldpack=args.worldpack, baseline=baseline, + benchmark_mode=args.benchmark_mode, max_chapters=int(args.max_chapters), min_end_turn_override=int(args.min_end_turn_override) if args.min_end_turn_override is not None else None, + execute_review_sampling_250=bool(args.execute_review_sampling_250), + execute_review_sampling_500=bool(args.execute_review_sampling_500), + execute_human_review_closeout_500=bool(args.execute_human_review_closeout_500), + human_review_closeout_500_reviewer_id=str(args.human_review_closeout_500_reviewer_id), + interactive_profile=args.interactive_profile, + acceptance_profile=str(args.acceptance_profile), + changed_worldpacks=_split_world_id_tokens(args.changed_worldpacks), + fast_gate_weakest_limit=int(args.fast_gate_weakest_limit), + progress_out=progress_out, + checkpoint_out=checkpoint_out, ) + artifact_started = perf_counter() + artifact_paths: Dict[str, object] = {} if args.markdown_out: markdown_path = Path(args.markdown_out) markdown_path.parent.mkdir(parents=True, exist_ok=True) markdown_path.write_text(render_benchmark_markdown(summary), encoding="utf-8") + artifact_paths["markdown"] = str(markdown_path) + if args.runtime_profile_out: + runtime_profile_path = Path(args.runtime_profile_out) + runtime_profile_path.parent.mkdir(parents=True, exist_ok=True) + artifact_paths["runtime_profile"] = str(runtime_profile_path) + if progress_out: + artifact_paths["progress"] = str(progress_out) + if checkpoint_out: + artifact_paths["checkpoint"] = str(checkpoint_out) + runtime_profile = dict(summary.get("benchmark_runtime_profile") or {}) + runtime_profile["artifact_write_ms"] = _elapsed_ms(artifact_started) + runtime_profile["artifact_paths"] = artifact_paths + summary["benchmark_runtime_profile"] = runtime_profile + if args.runtime_profile_out: + Path(args.runtime_profile_out).write_text( + json.dumps(runtime_profile, ensure_ascii=False, indent=2), + encoding="utf-8", + ) print(json.dumps(summary, ensure_ascii=False, indent=2)) return 0 diff --git a/src/narrativeos/content_quality_contracts.py b/src/narrativeos/content_quality_contracts.py new file mode 100644 index 0000000..f5c85a4 --- /dev/null +++ b/src/narrativeos/content_quality_contracts.py @@ -0,0 +1,1051 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence + + +DEFAULT_CONTENT_QUALITY_CONTRACTS_PATH = ( + Path(__file__).resolve().parents[2] / "configs" / "content_quality_contracts.json" +) + +DEFAULT_GENERATION_HARD_CONSTRAINTS: Dict[str, Any] = { + "config_version": "generation_hard_constraints_v1", + "repair_policy": "repair_once_then_fail_closed", + "universal_rules": { + "schema_complete": { + "issue_code": "Q10", + "action": "block", + "summary": "Reader chapter payload must include non-empty title, body, and branch choices.", + }, + "broken_slot": { + "issue_code": "Q10", + "action": "block", + "summary": "Template slot fragments cannot reach persisted reader prose.", + }, + "engineering_leak": { + "issue_code": "Q01", + "action": "block", + "summary": "Reader-visible prose cannot expose engine fields, ids, or route notation.", + }, + "meta_narration_leak": { + "issue_code": "Q02", + "action": "block", + "summary": "Reader-visible prose cannot explain chapter construction or planning intent.", + }, + "grounding_failed": { + "issue_code": "Q07", + "action": "block", + "summary": "Failed grounding cannot be persisted as a passed quality result.", + }, + "premature_terminal": { + "issue_code": "Q09", + "action": "block", + "summary": "Premature terminal chapters are blocked before the configured route runway.", + }, + "stock_refrain_budget": { + "issue_code": "Q03", + "action": "block", + "summary": "Known long-route refrain phrases must stay under deterministic budgets.", + }, + "choice_text_budget": { + "issue_code": "Q08", + "action": "block", + "summary": "Branch choices must remain non-empty and distinct within the current chapter.", + }, + }, + "base_thresholds": { + "min_choice_count": 2, + "min_body_text_units": 80, + "stock_refrain_current_max": 2, + "choice_text_current_max": 1, + }, + "genre_profiles": { + "mystery": { + "aliases": ["urban_mystery", "detective", "suspense"], + "threshold_overrides": {"stock_refrain_current_max": 2}, + }, + "romance": { + "aliases": ["romance", "relationship"], + "threshold_overrides": {"choice_text_current_max": 1}, + }, + "fantasy": { + "aliases": ["fantasy", "xianxia", "wuxia"], + "threshold_overrides": {}, + }, + "realist": { + "aliases": ["realist", "contemporary", "slice_of_life"], + "threshold_overrides": {}, + }, + "light_novel": { + "aliases": ["light_novel", "web_serial"], + "threshold_overrides": {"min_choice_count": 2}, + }, + }, + "length_profiles": { + "long_route_30": { + "min_chapters": 30, + "threshold_overrides": {"stock_refrain_current_max": 2}, + }, + "long_route_50": { + "min_chapters": 50, + "threshold_overrides": {"stock_refrain_current_max": 2}, + }, + }, +} + +DEFAULT_CONTENT_QUALITY_CONTRACTS: Dict[str, Any] = { + "config_version": "content_quality_contracts_v1", + "rolling_window_size": 5, + "full_chain_enforcement": True, + "generation_hard_constraints": DEFAULT_GENERATION_HARD_CONSTRAINTS, + "bands": { + "100": { + "enabled": True, + "diagnostic_enabled": True, + "gate_enforced": True, + "thresholds": { + "repetition_score_max": 0.20, + "exposition_ratio_max": 0.52, + "concrete_detail_density_min": 0.04, + "dialogue_plus_action_ratio_min": 0.42, + "late_window_hook_quality_min": 0.85, + "q09_pre_end_max": 0.08, + }, + "windows": { + "early": { + "start": 1, + "end": 10, + "q03_q04_combined_breach_share_max": 0.45, + }, + "mid": { + "start": 30, + "end": 60, + "repetition_breach_rate_max": 0.30, + "exposition_breach_rate_max": 0.30, + "detail_breach_rate_max": 0.35, + }, + "late": { + "start": 80, + "end": 100, + "q09_breach_rate_max": 0.08, + "detail_breach_rate_max": 0.35, + "premature_terminal_forbidden": True, + }, + }, + "enforcement_policy": { + "Q03": "rewrite", + "Q04": "rewrite", + "Q05": "rewrite", + "Q09_pre_end": "block", + "rolling_repeat_escalation": "block", + "rolling_exposition_escalation": "block", + }, + }, + "200": { + "enabled": False, + "diagnostic_enabled": True, + "gate_enforced": False, + "thresholds": { + "repetition_score_max": 0.18, + "exposition_ratio_max": 0.50, + "concrete_detail_density_min": 0.045, + "dialogue_plus_action_ratio_min": 0.46, + "late_window_hook_quality_min": 0.86, + "q09_pre_end_max": 0.10, + }, + "windows": { + "early": { + "start": 1, + "end": 20, + "q03_q04_combined_breach_share_max": 0.40, + }, + "mid": { + "start": 60, + "end": 140, + "repetition_breach_rate_max": 0.28, + "exposition_breach_rate_max": 0.28, + "detail_breach_rate_max": 0.32, + }, + "late": { + "start": 160, + "end": 200, + "q09_breach_rate_max": 0.12, + "detail_breach_rate_max": 0.32, + "premature_terminal_forbidden": True, + }, + }, + "enforcement_policy": { + "Q03": "rewrite", + "Q04": "rewrite", + "Q05": "rewrite", + "Q09_pre_end": "block", + "rolling_repeat_escalation": "block", + "rolling_exposition_escalation": "block", + }, + }, + "250": { + "enabled": False, + "diagnostic_enabled": False, + "gate_enforced": False, + "thresholds": { + "repetition_score_max": 0.20, + "exposition_ratio_max": 0.52, + "concrete_detail_density_min": 0.04, + "dialogue_plus_action_ratio_min": 0.42, + "late_window_hook_quality_min": 0.85, + "q09_pre_end_max": 0.08, + }, + "windows": { + "early": {"start": 1, "end": 10, "q03_q04_combined_breach_share_max": 0.45}, + "mid": {"start": 30, "end": 60, "repetition_breach_rate_max": 0.30, "exposition_breach_rate_max": 0.30, "detail_breach_rate_max": 0.35}, + "late": {"start": 80, "end": 100, "q09_breach_rate_max": 0.08, "detail_breach_rate_max": 0.35, "premature_terminal_forbidden": True}, + }, + "enforcement_policy": { + "Q03": "rewrite", + "Q04": "rewrite", + "Q05": "rewrite", + "Q09_pre_end": "block", + "rolling_repeat_escalation": "block", + "rolling_exposition_escalation": "block", + }, + }, + "500": { + "enabled": False, + "diagnostic_enabled": False, + "gate_enforced": False, + "thresholds": { + "repetition_score_max": 0.20, + "exposition_ratio_max": 0.52, + "concrete_detail_density_min": 0.04, + "dialogue_plus_action_ratio_min": 0.42, + "late_window_hook_quality_min": 0.85, + "q09_pre_end_max": 0.08, + }, + "windows": { + "early": {"start": 1, "end": 10, "q03_q04_combined_breach_share_max": 0.45}, + "mid": {"start": 30, "end": 60, "repetition_breach_rate_max": 0.30, "exposition_breach_rate_max": 0.30, "detail_breach_rate_max": 0.35}, + "late": {"start": 80, "end": 100, "q09_breach_rate_max": 0.08, "detail_breach_rate_max": 0.35, "premature_terminal_forbidden": True}, + }, + "enforcement_policy": { + "Q03": "rewrite", + "Q04": "rewrite", + "Q05": "rewrite", + "Q09_pre_end": "block", + "rolling_repeat_escalation": "block", + "rolling_exposition_escalation": "block", + }, + }, + "1000": { + "enabled": False, + "diagnostic_enabled": False, + "gate_enforced": False, + "thresholds": { + "repetition_score_max": 0.20, + "exposition_ratio_max": 0.52, + "concrete_detail_density_min": 0.04, + "dialogue_plus_action_ratio_min": 0.42, + "late_window_hook_quality_min": 0.85, + "q09_pre_end_max": 0.08, + }, + "windows": { + "early": {"start": 1, "end": 10, "q03_q04_combined_breach_share_max": 0.45}, + "mid": {"start": 30, "end": 60, "repetition_breach_rate_max": 0.30, "exposition_breach_rate_max": 0.30, "detail_breach_rate_max": 0.35}, + "late": {"start": 80, "end": 100, "q09_breach_rate_max": 0.08, "detail_breach_rate_max": 0.35, "premature_terminal_forbidden": True}, + }, + "enforcement_policy": { + "Q03": "rewrite", + "Q04": "rewrite", + "Q05": "rewrite", + "Q09_pre_end": "block", + "rolling_repeat_escalation": "block", + "rolling_exposition_escalation": "block", + }, + }, + }, +} + +SCENE_QUALITY_CONTRACT_KEYS = ( + "variation_axes", + "detail_anchor_types", + "dialogue_pressure", + "continuation_obligation", +) +CHAPTER_TASK_QUALITY_CONTRACT_KEYS = ( + "delayed_payoff_window", + "continuation_pressure_required", + "max_exposition_ratio", + "min_dialogue_action_ratio", + "min_detail_density", +) +ISSUE_CONTRACT_PRIORITY = ("Q09", "Q05", "Q04", "Q03") +CONTRACT_CHECK_TO_ISSUE_CODE = { + "repetition_score_cap": "Q03", + "rolling_window_repeat_breach": "Q03", + "event_coverage_gap_breach": "Q03", + "beat_coverage_gap_breach": "Q03", + "exposition_ratio_cap": "Q04", + "dialogue_action_floor": "Q04", + "rolling_window_exposition_breach": "Q04", + "detail_density_floor": "Q05", + "mid_window_detail_breach": "Q05", + "late_window_detail_breach": "Q05", + "continuation_pressure_floor": "Q09", + "premature_terminal_forbidden": "Q09", + "late_window_q09_breach": "Q09", + "q09_pre_end": "Q09", +} +ISSUE_ASSET_TARGETS: Dict[str, Dict[str, str]] = { + "Q03": { + "asset_type": "scene_blueprint", + "asset_label": "场景蓝图", + "validation_panel": "compare", + "validation_panel_label": "Compare", + }, + "Q04": { + "asset_type": "scene_blueprint", + "asset_label": "场景蓝图", + "validation_panel": "compare", + "validation_panel_label": "Compare", + }, + "Q05": { + "asset_type": "scene_blueprint", + "asset_label": "场景蓝图", + "validation_panel": "compare", + "validation_panel_label": "Compare", + }, + "Q09": { + "asset_type": "chapter_task", + "asset_label": "章节任务", + "validation_panel": "task_linking", + "validation_panel_label": "Task Linking", + }, +} + + +def _deep_copy_json(payload: Dict[str, Any]) -> Dict[str, Any]: + return json.loads(json.dumps(payload)) + + +def load_content_quality_contracts(path: Optional[Path] = None) -> Dict[str, Any]: + config_path = path or DEFAULT_CONTENT_QUALITY_CONTRACTS_PATH + payload = _deep_copy_json(DEFAULT_CONTENT_QUALITY_CONTRACTS) + if config_path.exists(): + try: + file_payload = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return payload + if isinstance(file_payload, dict): + payload.update({key: value for key, value in file_payload.items() if key != "bands"}) + if isinstance(file_payload.get("bands"), dict): + payload["bands"] = { + str(key): dict(value or {}) + for key, value in dict(file_payload.get("bands") or {}).items() + } + return payload + + +def content_quality_band_for_chapters(target_chapters: int) -> Optional[str]: + chapter_count = max(0, int(target_chapters or 0)) + if chapter_count >= 1000: + return "1000" + if chapter_count >= 500: + return "500" + if chapter_count >= 250: + return "250" + if chapter_count >= 200: + return "200" + if chapter_count >= 100: + return "100" + return None + + +def resolve_content_quality_contract( + *, + target_chapters: int, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + contracts = dict(config or load_content_quality_contracts()) + band = content_quality_band_for_chapters(target_chapters) + band_payload = dict((contracts.get("bands") or {}).get(str(band), {}) or {}) + return { + "config_version": str(contracts.get("config_version") or ""), + "rolling_window_size": int(contracts.get("rolling_window_size", 5) or 5), + "band": band, + "enabled": bool(band_payload.get("enabled", False)) if band else False, + "diagnostic_enabled": bool(band_payload.get("diagnostic_enabled", band_payload.get("enabled", False))) if band else False, + "gate_enforced": bool(band_payload.get("gate_enforced", band_payload.get("enabled", False))) if band else False, + "thresholds": dict(band_payload.get("thresholds") or {}), + "windows": dict(band_payload.get("windows") or {}), + "enforcement_policy": dict(band_payload.get("enforcement_policy") or {}), + "full_chain_enforcement": bool(contracts.get("full_chain_enforcement", True)), + } + + +def issue_asset_target(issue_code: str) -> Dict[str, str]: + return dict(ISSUE_ASSET_TARGETS.get(str(issue_code or ""), {})) + + +def contract_issue_codes_from_failed_checks(failed_checks: Sequence[str]) -> List[str]: + ordered: List[str] = [] + for check_name in list(failed_checks or []): + issue_code = CONTRACT_CHECK_TO_ISSUE_CODE.get(str(check_name or "")) + if issue_code and issue_code not in ordered: + ordered.append(issue_code) + return ordered + + +def is_quality_contract_applicable(worldpack_payload: Dict[str, Any], *, config: Optional[Dict[str, Any]] = None) -> bool: + metadata = dict(worldpack_payload.get("metadata") or {}) + benchmark_enabled = bool(metadata.get("benchmark_enabled", metadata.get("catalog_role", "published") == "published")) + target_chapters = int( + ((worldpack_payload.get("series_plan") or {}).get("total_chapter_target")) + or (((metadata.get("author_brief") or {}).get("target_total_chapters")) or 0) + or 0 + ) + contract = resolve_content_quality_contract(target_chapters=target_chapters, config=config) + return benchmark_enabled and target_chapters >= 100 and bool( + contract.get("enabled", False) or contract.get("diagnostic_enabled", False) + ) + + +def _scene_dialogue_pressure(scene_function: str) -> str: + high = {"truth_trial", "humiliation", "debt_exchange", "karma_ripening", "vow_payment"} + medium = {"temptation", "mask_crack", "misrecognition", "confession_window"} + normalized = str(scene_function or "") + if normalized in high: + return "high" + if normalized in medium: + return "medium" + return "low" + + +def build_scene_quality_contract( + *, + scene_function: str, + phase_support: Optional[Sequence[str]] = None, +) -> Dict[str, Any]: + normalized = str(scene_function or "") + variation_axes = ["voice", "movement", "location_object", "consequence"] + if normalized in {"misrecognition", "truth_trial", "confession_window"}: + variation_axes.append("information_reveal") + detail_anchor_types = ["object", "sound", "body_motion"] + if normalized in {"false_peace", "temptation", "misrecognition"}: + detail_anchor_types.append("ambient_signal") + return { + "variation_axes": variation_axes, + "detail_anchor_types": detail_anchor_types, + "dialogue_pressure": _scene_dialogue_pressure(normalized), + "continuation_obligation": bool(phase_support or True), + } + + +def build_chapter_task_quality_contract( + *, + duty_type: str, + target_chapters: int, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + contract = resolve_content_quality_contract(target_chapters=target_chapters, config=config) + thresholds = dict(contract.get("thresholds") or {}) + delayed_payoff_window = {"min_chapters": 3, "max_chapters": 10} + if duty_type == "resolve_promise": + delayed_payoff_window = {"min_chapters": 1, "max_chapters": 4} + elif duty_type == "deliver_climax": + delayed_payoff_window = {"min_chapters": 1, "max_chapters": 5} + elif duty_type == "pace_breath": + delayed_payoff_window = {"min_chapters": 2, "max_chapters": 6} + return { + "delayed_payoff_window": delayed_payoff_window, + "continuation_pressure_required": True, + "max_exposition_ratio": float(thresholds.get("exposition_ratio_max", 0.52) or 0.52), + "min_dialogue_action_ratio": float(thresholds.get("dialogue_plus_action_ratio_min", 0.42) or 0.42), + "min_detail_density": float(thresholds.get("concrete_detail_density_min", 0.04) or 0.04), + } + + +def ensure_scene_quality_contract(scene_payload: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(scene_payload or {}) + quality_contract = dict(payload.get("quality_contract") or {}) + if not quality_contract: + quality_contract = build_scene_quality_contract( + scene_function=str(payload.get("scene_function") or ""), + phase_support=list(payload.get("phase_support") or []), + ) + payload["quality_contract"] = quality_contract + return payload + + +def ensure_chapter_task_quality_contract( + task_payload: Dict[str, Any], + *, + target_chapters: int, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + payload = dict(task_payload or {}) + quality_contract = dict(payload.get("quality_contract") or {}) + if not quality_contract: + quality_contract = build_chapter_task_quality_contract( + duty_type=str(payload.get("duty_type") or ""), + target_chapters=target_chapters, + config=config, + ) + payload["quality_contract"] = quality_contract + return payload + + +def asset_quality_contract_coverage( + worldpack_payload: Dict[str, Any], + *, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + metadata = dict(worldpack_payload.get("metadata") or {}) + target_chapters = int( + ((worldpack_payload.get("series_plan") or {}).get("total_chapter_target")) + or (((metadata.get("author_brief") or {}).get("target_total_chapters")) or 0) + or 0 + ) + contract = resolve_content_quality_contract(target_chapters=target_chapters, config=config) + applicable = is_quality_contract_applicable(worldpack_payload, config=config) + scene_blueprints = [dict(item) for item in list(worldpack_payload.get("scene_blueprints") or [])] + chapter_tasks = [ + dict(task) + for arc in list(worldpack_payload.get("arc_plans") or []) + for task in list(dict(arc or {}).get("chapter_tasks") or []) + ] + missing_scene_ids = [ + str(item.get("scene_id") or "") + for item in scene_blueprints + if any(key not in dict(item.get("quality_contract") or {}) for key in SCENE_QUALITY_CONTRACT_KEYS) + ] + missing_task_ids = [ + str(item.get("chapter_task_id") or "") + for item in chapter_tasks + if any(key not in dict(item.get("quality_contract") or {}) for key in CHAPTER_TASK_QUALITY_CONTRACT_KEYS) + ] + characters = [ + dict(item or {}) + for item in list(worldpack_payload.get("characters") or []) + if str(dict(item or {}).get("character_id") or "").strip() + ] + character_ids = [str(item.get("character_id") or "") for item in characters] + voice_profiles = dict(worldpack_payload.get("voice_profiles") or {}) + missing_voice_profile_character_ids = [] + for character in characters: + character_id = str(character.get("character_id") or "").strip() + role_key = str(character.get("role") or "").strip() + if character_id in voice_profiles or (role_key and role_key in voice_profiles): + continue + if role_key and role_key not in {"lead", "counterpart"}: + continue + missing_voice_profile_character_ids.append(character_id) + locations = [str(item).strip() for item in list((worldpack_payload.get("world_bible") or {}).get("locations") or []) if str(item).strip()] + sensory_policies = dict(worldpack_payload.get("sensory_grounding_policies") or {}) + covered_locations = { + str(location).strip() + for policy in sensory_policies.values() + for location in dict(policy or {}).get("location_slots", {}) + if str(location).strip() + } + missing_sensory_locations = [location for location in locations if location not in covered_locations] + has_dialogue_realism_policy = bool(worldpack_payload.get("dialogue_realism_policy")) + has_scene_realization_contracts = bool(worldpack_payload.get("scene_realization_contracts")) + failed_checks: List[str] = [] + if applicable and missing_scene_ids: + failed_checks.append("scene_blueprint_quality_contract_missing") + if applicable and missing_task_ids: + failed_checks.append("chapter_task_quality_contract_missing") + if applicable and missing_voice_profile_character_ids: + failed_checks.append("voice_profile_character_coverage_missing") + if applicable and missing_sensory_locations: + failed_checks.append("sensory_grounding_location_coverage_missing") + if applicable and not has_dialogue_realism_policy: + failed_checks.append("dialogue_realism_policy_missing") + if applicable and not has_scene_realization_contracts: + failed_checks.append("scene_realization_contracts_missing") + return { + "applicable": applicable, + "band": contract.get("band"), + "config_version": contract.get("config_version"), + "diagnostic_enabled": bool(contract.get("diagnostic_enabled", False)), + "gate_enforced": bool(contract.get("gate_enforced", False)), + "ok": not failed_checks, + "failed_checks": failed_checks, + "scene_blueprint_count": len(scene_blueprints), + "chapter_task_count": len(chapter_tasks), + "scene_blueprint_quality_contract_coverage": len(scene_blueprints) - len(missing_scene_ids), + "chapter_task_quality_contract_coverage": len(chapter_tasks) - len(missing_task_ids), + "missing_scene_ids": missing_scene_ids, + "missing_chapter_task_ids": missing_task_ids, + "missing_voice_profile_character_ids": missing_voice_profile_character_ids, + "missing_sensory_locations": missing_sensory_locations, + "has_dialogue_realism_policy": has_dialogue_realism_policy, + "has_scene_realization_contracts": has_scene_realization_contracts, + } + + +def _window_label_for_chapter(chapter_index: int, windows: Dict[str, Any]) -> str: + chapter = int(chapter_index or 0) + for label in ("early", "mid", "late"): + window = dict(windows.get(label) or {}) + start = int(window.get("start", 0) or 0) + end = int(window.get("end", 0) or 0) + if start and end and start <= chapter <= end: + return label + return "general" + + +def _safe_float(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _issue_codes_from_report(report: Any) -> List[str]: + return [ + str(item.get("issue_code") or "") + for item in list([issue.to_dict() for issue in list(report.issues or [])] if getattr(report, "issues", None) is not None else []) + if str(item.get("issue_code") or "") + ] + + +def diagnostic_issue_codes_for_chapter_payload( + payload: Dict[str, Any], + *, + target_chapters: int, + config: Optional[Dict[str, Any]] = None, +) -> List[str]: + contract = resolve_content_quality_contract(target_chapters=target_chapters, config=config) + if not (contract.get("enabled") or contract.get("diagnostic_enabled")): + return [] + thresholds = dict(contract.get("thresholds") or {}) + windows = dict(contract.get("windows") or {}) + chapter_id = str(payload.get("chapter_id") or "") + suffix = chapter_id.rsplit("_", 1)[-1] + chapter_index = int(suffix) if suffix.isdigit() else 0 + issue_codes = {str(item.get("issue_code") or "") for item in list(payload.get("issues") or []) if str(item.get("issue_code") or "")} + lint_metrics = dict((payload.get("hard_validator_results") or {}).get("lint_metrics") or {}) + failed_checks: List[str] = [] + repetition_score = _safe_float(lint_metrics.get("repetition_score")) + exposition_ratio = _safe_float(lint_metrics.get("exposition_ratio")) + detail_density = _safe_float(lint_metrics.get("concrete_detail_density")) + dialogue_ratio = _safe_float(lint_metrics.get("dialogue_plus_action_ratio")) + hook_quality = _safe_float(((payload.get("scores") or {}).get("hook_quality"))) + repetition_bundle = dict(lint_metrics.get("repetition_signal_bundle") or {}) + if repetition_score > float(thresholds.get("repetition_score_max", 0.20) or 0.20): + failed_checks.append("repetition_score_cap") + if float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) > 0.42: + failed_checks.append("event_coverage_gap_breach") + if float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) > 0.35: + failed_checks.append("beat_coverage_gap_breach") + if exposition_ratio > float(thresholds.get("exposition_ratio_max", 0.52) or 0.52): + failed_checks.append("exposition_ratio_cap") + if detail_density < float(thresholds.get("concrete_detail_density_min", 0.04) or 0.04): + failed_checks.append("detail_density_floor") + if dialogue_ratio < float(thresholds.get("dialogue_plus_action_ratio_min", 0.42) or 0.42): + failed_checks.append("dialogue_action_floor") + if chapter_index < int(target_chapters * 0.96 or 0) and "Q09" in issue_codes: + failed_checks.append("q09_pre_end") + current_window_label = _window_label_for_chapter(chapter_index, windows) + mid = dict(windows.get("mid") or {}) + late = dict(windows.get("late") or {}) + if current_window_label == "mid" and detail_density < float(thresholds.get("concrete_detail_density_min", 0.04) or 0.04): + failed_checks.append("mid_window_detail_breach") + if current_window_label == "late": + if "Q09" in issue_codes: + failed_checks.append("late_window_q09_breach") + if detail_density < float(thresholds.get("concrete_detail_density_min", 0.04) or 0.04): + failed_checks.append("late_window_detail_breach") + if hook_quality < float(thresholds.get("late_window_hook_quality_min", 0.85) or 0.85): + failed_checks.append("continuation_pressure_floor") + return contract_issue_codes_from_failed_checks(failed_checks) + + +def next_quality_contract_window( + rolling_quality_window: Sequence[Dict[str, Any]], + *, + chapter_index: int, + decision: str, + issue_codes: Sequence[str], + repetition_score: float, + exposition_ratio: float, + concrete_detail_density: float, + dialogue_plus_action_ratio: float, + hook_quality: float, + scene_function: str, + chapter_task_id: str, + window_size: int, +) -> List[Dict[str, Any]]: + entries = [dict(item or {}) for item in rolling_quality_window][-max(0, int(window_size or 0)) :] + entries.append( + { + "chapter_index": int(chapter_index or 0), + "decision": str(decision or ""), + "issue_codes": [str(item) for item in issue_codes if str(item)], + "repetition_score": round(_safe_float(repetition_score), 3), + "exposition_ratio": round(_safe_float(exposition_ratio), 3), + "concrete_detail_density": round(_safe_float(concrete_detail_density), 3), + "dialogue_plus_action_ratio": round(_safe_float(dialogue_plus_action_ratio), 3), + "hook_quality": round(_safe_float(hook_quality), 3), + "scene_function": str(scene_function or ""), + "chapter_task_id": str(chapter_task_id or ""), + } + ) + return entries[-max(1, int(window_size or 1)) :] + + +def resolve_scene_quality_contract_from_coverage(coverage_context: Optional[Dict[str, Any]]) -> Dict[str, Any]: + payload = dict(coverage_context or {}) + scene_beats = list(payload.get("scene_beats") or []) + for beat in scene_beats: + scene_payload = dict(beat or {}) + if dict(scene_payload.get("quality_contract") or {}): + return dict(scene_payload.get("quality_contract") or {}) + return {} + + +def resolve_scene_function_from_coverage(coverage_context: Optional[Dict[str, Any]]) -> str: + payload = dict(coverage_context or {}) + scene_beats = list(payload.get("scene_beats") or []) + for beat in scene_beats: + event = dict(dict(beat or {}).get("event") or {}) + if str(event.get("scene_function") or ""): + return str(event.get("scene_function") or "") + return str(payload.get("scene_function") or "") + + +def resolve_chapter_task_quality_contract_from_coverage(coverage_context: Optional[Dict[str, Any]]) -> Dict[str, Any]: + payload = dict(coverage_context or {}) + chapter_task = dict(payload.get("chapter_task") or {}) + return dict(chapter_task.get("quality_contract") or {}) + + +def evaluate_chapter_quality_contract( + *, + report: Any, + chapter_index: int, + target_chapters: int, + story_phase: str, + scene_quality_contract: Optional[Dict[str, Any]] = None, + chapter_task_quality_contract: Optional[Dict[str, Any]] = None, + rolling_quality_window: Optional[Sequence[Dict[str, Any]]] = None, + scene_function: str = "", + chapter_task_id: str = "", + ending_ready: bool = False, + enforcement_scope: str = "persisted_chapter", + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + contract = resolve_content_quality_contract(target_chapters=target_chapters, config=config) + thresholds = dict(contract.get("thresholds") or {}) + windows = dict(contract.get("windows") or {}) + if not contract.get("enabled"): + return { + "enabled": False, + "ok": True, + "contract_checks": [], + "contract_thresholds": {"config_version": contract.get("config_version"), "band": contract.get("band")}, + "failed_contract_checks": [], + "primary_issue_group": "", + "primary_asset_target": {}, + "window_breach_kind": "", + "enforcement_scope": enforcement_scope, + "quality_contract_window": list(rolling_quality_window or []), + } + + lint_metrics = dict((report.hard_validator_results or {}).get("lint_metrics") or {}) + issue_codes = _issue_codes_from_report(report) + repetition_score = _safe_float(lint_metrics.get("repetition_score")) + exposition_ratio = _safe_float(lint_metrics.get("exposition_ratio")) + detail_density = _safe_float(lint_metrics.get("concrete_detail_density")) + dialogue_ratio = _safe_float(lint_metrics.get("dialogue_plus_action_ratio")) + hook_quality = _safe_float(getattr(getattr(report, "scores", None), "hook_quality", 0.0)) + decision = str((getattr(report, "decision", None) or {}).decision if getattr(report, "decision", None) else "") + chapter_task_contract = dict(chapter_task_quality_contract or {}) + window_size = int(contract.get("rolling_window_size", 5) or 5) + quality_window = next_quality_contract_window( + list(rolling_quality_window or []), + chapter_index=chapter_index, + decision=decision, + issue_codes=issue_codes, + repetition_score=repetition_score, + exposition_ratio=exposition_ratio, + concrete_detail_density=detail_density, + dialogue_plus_action_ratio=dialogue_ratio, + hook_quality=hook_quality, + scene_function=str(scene_function or ""), + chapter_task_id=str(chapter_task_id or ""), + window_size=window_size, + ) + current_window_label = _window_label_for_chapter(chapter_index, windows) + repetition_cap = float(thresholds.get("repetition_score_max", 0.20) or 0.20) + exposition_cap = float(chapter_task_contract.get("max_exposition_ratio", thresholds.get("exposition_ratio_max", 0.52)) or 0.52) + detail_floor = float(chapter_task_contract.get("min_detail_density", thresholds.get("concrete_detail_density_min", 0.04)) or 0.04) + dialogue_floor = float(chapter_task_contract.get("min_dialogue_action_ratio", thresholds.get("dialogue_plus_action_ratio_min", 0.42)) or 0.42) + continuation_required = bool(chapter_task_contract.get("continuation_pressure_required", False) or dict(scene_quality_contract or {}).get("continuation_obligation", False)) + hook_floor = float(thresholds.get("late_window_hook_quality_min", 0.85) or 0.85) + completion_ratio = round(int(chapter_index or 0) / float(max(1, int(target_chapters or 1))), 3) + late_window = dict(windows.get("late") or {}) + late_window_entries = [ + item + for item in quality_window + if int(item.get("chapter_index", 0) or 0) >= int(late_window.get("start", target_chapters + 1) or target_chapters + 1) + ] + late_q09_rate = ( + sum(1 for item in late_window_entries if "Q09" in list(item.get("issue_codes") or [])) + / float(max(1, len(late_window_entries))) + if late_window_entries + else 0.0 + ) + recent_entries = quality_window[-2:] + rolling_repeat_breach = len(recent_entries) == 2 and all( + _safe_float(item.get("repetition_score")) > repetition_cap or "Q03" in list(item.get("issue_codes") or []) + for item in recent_entries + ) + rolling_exposition_breach = len(recent_entries) == 2 and all( + _safe_float(item.get("exposition_ratio")) > exposition_cap or "Q04" in list(item.get("issue_codes") or []) + for item in recent_entries + ) + contract_checks = [ + { + "name": "repetition_score_cap", + "ok": repetition_score <= repetition_cap, + "actual": round(repetition_score, 3), + "threshold": round(repetition_cap, 3), + "issue_code": "Q03", + "window_label": current_window_label, + }, + { + "name": "exposition_ratio_cap", + "ok": exposition_ratio <= exposition_cap, + "actual": round(exposition_ratio, 3), + "threshold": round(exposition_cap, 3), + "issue_code": "Q04", + "window_label": current_window_label, + }, + { + "name": "detail_density_floor", + "ok": detail_density >= detail_floor, + "actual": round(detail_density, 3), + "threshold": round(detail_floor, 3), + "issue_code": "Q05", + "window_label": current_window_label, + }, + { + "name": "dialogue_action_floor", + "ok": dialogue_ratio >= dialogue_floor, + "actual": round(dialogue_ratio, 3), + "threshold": round(dialogue_floor, 3), + "issue_code": "Q04", + "window_label": current_window_label, + }, + { + "name": "continuation_pressure_floor", + "ok": (not continuation_required) or current_window_label != "late" or hook_quality >= hook_floor, + "actual": round(hook_quality, 3), + "threshold": round(hook_floor, 3), + "issue_code": "Q09", + "window_label": current_window_label, + "applicable": continuation_required and current_window_label == "late", + }, + { + "name": "premature_terminal_forbidden", + "ok": not ending_ready or completion_ratio >= 0.96, + "actual": bool(ending_ready), + "threshold": False, + "issue_code": "Q09", + "window_label": current_window_label, + }, + { + "name": "rolling_window_repeat_breach", + "ok": not rolling_repeat_breach, + "actual": rolling_repeat_breach, + "threshold": False, + "issue_code": "Q03", + "window_label": current_window_label, + }, + { + "name": "rolling_window_exposition_breach", + "ok": not rolling_exposition_breach, + "actual": rolling_exposition_breach, + "threshold": False, + "issue_code": "Q04", + "window_label": current_window_label, + }, + { + "name": "late_window_q09_breach", + "ok": current_window_label != "late" or late_q09_rate <= float(late_window.get("q09_breach_rate_max", thresholds.get("q09_pre_end_max", 0.08)) or 0.08), + "actual": round(late_q09_rate, 3), + "threshold": round(float(late_window.get("q09_breach_rate_max", thresholds.get("q09_pre_end_max", 0.08)) or 0.08), 3), + "issue_code": "Q09", + "window_label": current_window_label, + }, + ] + failed_contract_checks = [item for item in contract_checks if not item.get("ok", True)] + failed_issue_codes = [ + str(item.get("issue_code") or "") + for item in failed_contract_checks + if str(item.get("issue_code") or "") + ] + primary_issue_group = next((issue_code for issue_code in ISSUE_CONTRACT_PRIORITY if issue_code in failed_issue_codes), "") + if not primary_issue_group: + primary_issue_group = next((issue_code for issue_code in ISSUE_CONTRACT_PRIORITY if issue_code in issue_codes), "") + primary_asset_target = issue_asset_target(primary_issue_group) + window_breach_kind = str(failed_contract_checks[0].get("name") or "") if failed_contract_checks else "" + return { + "enabled": True, + "ok": not failed_contract_checks, + "contract_checks": contract_checks, + "contract_thresholds": { + "config_version": contract.get("config_version"), + "band": contract.get("band"), + "thresholds": thresholds, + "windows": windows, + }, + "failed_contract_checks": [str(item.get("name") or "") for item in failed_contract_checks], + "primary_issue_group": primary_issue_group, + "primary_asset_target": primary_asset_target, + "window_breach_kind": window_breach_kind, + "blocking_dimension": primary_issue_group, + "enforcement_scope": enforcement_scope, + "quality_contract_window": quality_window, + "completion_ratio": completion_ratio, + } + + +def content_quality_window_metrics( + *, + chapter_report_payloads: Sequence[Dict[str, Any]], + world_metrics: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, + diagnostic_issue_code_resolver: Optional[Any] = None, +) -> Dict[str, Any]: + metrics = dict(world_metrics or {}) + target_chapters = int(metrics.get("target_chapters", 0) or 0) + contract = resolve_content_quality_contract(target_chapters=target_chapters, config=config) + if not (contract.get("enabled") or contract.get("diagnostic_enabled")): + return { + "enabled": False, + "gate_enforced": False, + "diagnostic_enabled": False, + "band": contract.get("band"), + "config_version": contract.get("config_version"), + "early_window_q03_q04_share": 0.0, + "mid_window_repeat_breach_rate": 0.0, + "mid_window_exposition_breach_rate": 0.0, + "mid_window_detail_breach_rate": 0.0, + "late_window_q09_breach_rate": 0.0, + "late_window_detail_breach_rate": 0.0, + "contract_failed_chapters": [], + } + thresholds = dict(contract.get("thresholds") or {}) + windows = dict(contract.get("windows") or {}) + early = dict(windows.get("early") or {}) + mid = dict(windows.get("mid") or {}) + late = dict(windows.get("late") or {}) + early_payloads = [] + mid_payloads = [] + late_payloads = [] + failed_chapters = [] + contract_issue_surface_counts = {"Q03": 0, "Q04": 0, "Q05": 0, "Q09": 0} + for payload in list(chapter_report_payloads or []): + chapter_id = str(payload.get("chapter_id") or "") + suffix = chapter_id.rsplit("_", 1)[-1] + chapter_index = int(suffix) if suffix.isdigit() else 0 + issue_codes = {str(item.get("issue_code") or "") for item in list(payload.get("issues") or []) if str(item.get("issue_code") or "")} + lint_metrics = dict((payload.get("hard_validator_results") or {}).get("lint_metrics") or {}) + repetition_score = _safe_float(lint_metrics.get("repetition_score")) + exposition_ratio = _safe_float(lint_metrics.get("exposition_ratio")) + hook_quality = _safe_float(((payload.get("scores") or {}).get("hook_quality"))) + decision = str(dict(payload.get("decision") or {}).get("decision") or "") + current_window_label = _window_label_for_chapter(chapter_index, windows) + if int(early.get("start", 0) or 0) <= chapter_index <= int(early.get("end", 0) or -1): + early_payloads.append(payload) + if int(mid.get("start", 0) or 0) <= chapter_index <= int(mid.get("end", 0) or -1): + mid_payloads.append(payload) + if int(late.get("start", 0) or 0) <= chapter_index <= int(late.get("end", 0) or -1): + late_payloads.append(payload) + failed_names = [] + if repetition_score > float(thresholds.get("repetition_score_max", 0.20) or 0.20): + failed_names.append("repetition_score_cap") + if exposition_ratio > float(thresholds.get("exposition_ratio_max", 0.52) or 0.52): + failed_names.append("exposition_ratio_cap") + if _safe_float(lint_metrics.get("concrete_detail_density")) < float(thresholds.get("concrete_detail_density_min", 0.04) or 0.04): + failed_names.append("detail_density_floor") + if _safe_float(lint_metrics.get("dialogue_plus_action_ratio")) < float(thresholds.get("dialogue_plus_action_ratio_min", 0.42) or 0.42): + failed_names.append("dialogue_action_floor") + if current_window_label == "mid" and _safe_float(lint_metrics.get("concrete_detail_density")) < float(thresholds.get("concrete_detail_density_min", 0.04) or 0.04): + failed_names.append("mid_window_detail_breach") + if chapter_index < int(target_chapters * 0.96 or 0) and "Q09" in issue_codes: + failed_names.append("q09_pre_end") + if chapter_index >= int(late.get("start", target_chapters + 1) or target_chapters + 1) and hook_quality < float(thresholds.get("late_window_hook_quality_min", 0.85) or 0.85): + failed_names.append("continuation_pressure_floor") + if current_window_label == "late" and _safe_float(lint_metrics.get("concrete_detail_density")) < float(thresholds.get("concrete_detail_density_min", 0.04) or 0.04): + failed_names.append("late_window_detail_breach") + if failed_names: + failed_chapters.append({"chapter_id": chapter_id, "chapter_index": chapter_index, "failed_checks": failed_names, "decision": decision}) + diagnostic_issue_codes = ( + diagnostic_issue_code_resolver(payload, target_chapters=target_chapters) + if diagnostic_issue_code_resolver is not None + else diagnostic_issue_codes_for_chapter_payload(payload, target_chapters=target_chapters, config=config) + ) + for issue_code in diagnostic_issue_codes: + if issue_code in contract_issue_surface_counts: + contract_issue_surface_counts[issue_code] += 1 + early_breach = sum( + 1 + for payload in early_payloads + if {"Q03", "Q04"} & {str(item.get("issue_code") or "") for item in list(payload.get("issues") or [])} + ) + mid_repeat = sum( + 1 + for payload in mid_payloads + if _safe_float(dict((payload.get("hard_validator_results") or {}).get("lint_metrics") or {}).get("repetition_score")) > float(thresholds.get("repetition_score_max", 0.20) or 0.20) + ) + mid_exposition = sum( + 1 + for payload in mid_payloads + if _safe_float(dict((payload.get("hard_validator_results") or {}).get("lint_metrics") or {}).get("exposition_ratio")) > float(thresholds.get("exposition_ratio_max", 0.52) or 0.52) + ) + mid_detail = sum( + 1 + for payload in mid_payloads + if _safe_float(dict((payload.get("hard_validator_results") or {}).get("lint_metrics") or {}).get("concrete_detail_density")) < float(thresholds.get("concrete_detail_density_min", 0.04) or 0.04) + ) + late_q09 = sum( + 1 + for payload in late_payloads + if "Q09" in {str(item.get("issue_code") or "") for item in list(payload.get("issues") or [])} + ) + late_detail = sum( + 1 + for payload in late_payloads + if _safe_float(dict((payload.get("hard_validator_results") or {}).get("lint_metrics") or {}).get("concrete_detail_density")) < float(thresholds.get("concrete_detail_density_min", 0.04) or 0.04) + ) + return { + "enabled": True, + "gate_enforced": bool(contract.get("gate_enforced", False)), + "diagnostic_enabled": bool(contract.get("diagnostic_enabled", False)), + "band": contract.get("band"), + "config_version": contract.get("config_version"), + "early_window_q03_q04_share": round(early_breach / float(max(1, len(early_payloads))), 3), + "mid_window_repeat_breach_rate": round(mid_repeat / float(max(1, len(mid_payloads))), 3), + "mid_window_exposition_breach_rate": round(mid_exposition / float(max(1, len(mid_payloads))), 3), + "mid_window_detail_breach_rate": round(mid_detail / float(max(1, len(mid_payloads))), 3), + "late_window_q09_breach_rate": round(late_q09 / float(max(1, len(late_payloads))), 3), + "late_window_detail_breach_rate": round(late_detail / float(max(1, len(late_payloads))), 3), + "contract_failed_chapters": failed_chapters, + "contract_issue_surface_counts": contract_issue_surface_counts, + "contract_issue_surface_rates": { + issue_code: round(count / float(max(1, len(list(chapter_report_payloads or [])))), 3) + for issue_code, count in contract_issue_surface_counts.items() + }, + "thresholds": { + "early_window_q03_q04_share_max": float(early.get("q03_q04_combined_breach_share_max", 0.45) or 0.45), + "mid_window_repeat_breach_rate_max": float(mid.get("repetition_breach_rate_max", 0.30) or 0.30), + "mid_window_exposition_breach_rate_max": float(mid.get("exposition_breach_rate_max", 0.30) or 0.30), + "mid_window_detail_breach_rate_max": float(mid.get("detail_breach_rate_max", 0.35) or 0.35), + "late_window_q09_breach_rate_max": float(late.get("q09_breach_rate_max", thresholds.get("q09_pre_end_max", 0.08)) or 0.08), + "late_window_detail_breach_rate_max": float(late.get("detail_breach_rate_max", 0.35) or 0.35), + }, + } diff --git a/src/narrativeos/content_quality_strategy_bundles.py b/src/narrativeos/content_quality_strategy_bundles.py new file mode 100644 index 0000000..92ae8e7 --- /dev/null +++ b/src/narrativeos/content_quality_strategy_bundles.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + + +DEFAULT_CONTENT_QUALITY_STRATEGY_BUNDLES_PATH = ( + Path(__file__).resolve().parents[2] / "configs" / "content_quality_strategy_bundles.json" +) + +DEFAULT_CONTENT_QUALITY_STRATEGY_BUNDLES: Dict[str, Any] = { + "config_version": "content_quality_strategy_bundles_v1", + "bundles": {}, +} + + +def load_content_quality_strategy_bundles(path: Optional[Path] = None) -> Dict[str, Any]: + config_path = path or DEFAULT_CONTENT_QUALITY_STRATEGY_BUNDLES_PATH + payload = dict(DEFAULT_CONTENT_QUALITY_STRATEGY_BUNDLES) + if config_path.exists(): + try: + file_payload = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return payload + if isinstance(file_payload, dict): + payload.update(file_payload) + return payload + + +def _bundle_id_for_issue_codes(issue_codes: Sequence[str]) -> str: + normalized = {str(item or "") for item in issue_codes if str(item or "")} + if {"Q03", "Q04"} <= normalized: + return "q03_q04_scene_dialogue_cadence_task_coupling" + if "Q09" in normalized: + return "q09_continuation_runway" + if "Q04" in normalized: + return "q04_scene_dialogue_cadence" + if "Q03" in normalized: + return "q03_scene_dialogue_cadence" + if "Q05" in normalized: + return "q05_scene_grounding_detail" + return "" + + +def _asset_type_from_path(path: str) -> str: + if path.startswith("scene_blueprints["): + return "scene_blueprint" + if path.startswith('scene_realization_contracts["default"]'): + return "scene_realization_contracts" + if path.startswith('emotion_action_policies["default"]'): + return "emotion_action_policies" + if path.startswith('voice_profiles["'): + return "voice_profiles" + if path.startswith('response_cadence_profiles["'): + return "response_cadence_profiles" + if path.startswith('characters[character_id="'): + return "character_card" + if path.startswith('arc_plans['): + return "chapter_task_coupling" if ".chapter_tasks[" in path else "arc_plan" + return "" + + +def _metric_direction(metric_name: str) -> str: + increasing = { + "dialogue_ratio", + "scene_detail_density", + "late_arc_pass_rate", + "mid_arc_pass_rate", + "pass_rate", + } + return "increase" if metric_name in increasing else "decrease" + + +def _stop_condition_payload(rule_id: str) -> Dict[str, Any]: + mapping = { + "upgrade_to_planner_or_pack_contract_if_two_reruns_flat": { + "description": "如果连续两次 full rerun 主要窗口指标持平或回退,就升级到 planner / pack-level contract。", + "tripwire": "two_reruns_flat_or_regressed", + }, + "upgrade_to_task_coupling_if_flat": { + "description": "如果 scene/dialogue/cadence 修完后窗口仍持平,就升级到 task coupling。", + "tripwire": "scene_dialogue_cadence_flat", + }, + "upgrade_to_budget_and_task_balance_if_flat": { + "description": "如果 grounding/detail 修完后仍持平,就升级到 budget/task balance。", + "tripwire": "detail_bundle_flat", + }, + "upgrade_to_planner_contract_if_flat": { + "description": "如果 continuation runway 修完后仍持平,就升级到 planner contract。", + "tripwire": "continuation_bundle_flat", + }, + } + payload = dict(mapping.get(rule_id, {})) + return { + "rule_id": rule_id, + "description": payload.get("description", ""), + "tripwire": payload.get("tripwire", ""), + } + + +def _bundle_step_planning( + *, + bundle_id: str, + bundle_label: str, + asset_sequence: Sequence[str], + target_by_type: Dict[str, Dict[str, Any]], + edits_by_asset: Dict[str, List[Dict[str, Any]]], + validation_sequence: Sequence[str], +) -> List[Dict[str, Any]]: + steps: List[Dict[str, Any]] = [] + for index, asset_type in enumerate(list(asset_sequence or []), start=1): + target = dict(target_by_type.get(asset_type) or {}) + step_edits = list(edits_by_asset.get(asset_type, [])) + if not target and not step_edits: + continue + steps.append( + { + "step_id": f"{bundle_id}::step_{index}", + "bundle_label": bundle_label, + "apply_order": len(steps) + 1, + "step_kind": "asset_apply", + "asset_type": asset_type, + "target": target, + "validation_panel": str(target.get("validation_panel") or ""), + "validation_panel_label": str(target.get("validation_panel_label") or ""), + "suggested_field_edits": step_edits, + "post_apply_validation": ( + list(validation_sequence or []) + if asset_type in {"chapter_task", "chapter_task_coupling", "arc_plan"} + else [str(target.get("validation_panel") or "compare")] + ), + } + ) + return steps + + +def _rerun_attribution_payload( + *, + window_label: str, + success_metrics: Sequence[str], +) -> Dict[str, Any]: + return { + "rerun_scope": "full_100_rerun", + "compare_scope": "window_slice", + "window_label": window_label, + "metrics_to_watch": [ + { + "metric": str(metric_name), + "direction": _metric_direction(str(metric_name)), + } + for metric_name in list(success_metrics or []) + ], + "attribution_rule": "first_compare_bundle_metrics_then_window_metrics_then_global_quality", + "result_receipt_fields": [ + "baseline_window_issue_count", + "current_window_issue_count", + "baseline_window_worst_decision", + "current_window_worst_decision", + "ready_for_validation", + ], + } + + +def build_strategy_bundle( + *, + issue_codes: Sequence[str], + window_label: str, + primary_asset_target: Dict[str, Any], + secondary_asset_targets: Sequence[Dict[str, Any]], + suggested_actions: Sequence[Dict[str, Any]], + suggested_field_edits: Sequence[Dict[str, Any]], + targeted_chapter_indices: Sequence[int], +) -> Dict[str, Any]: + config = load_content_quality_strategy_bundles() + bundle_id = _bundle_id_for_issue_codes(issue_codes) + bundle_payload = dict((config.get("bundles") or {}).get(bundle_id, {}) or {}) + if not bundle_id: + return {} + + asset_targets: List[Dict[str, Any]] = [dict(primary_asset_target or {})] + [dict(item or {}) for item in list(secondary_asset_targets or [])] + target_by_type = { + str(item.get("asset_type") or ""): dict(item) + for item in asset_targets + if str(item.get("asset_type") or "") + } + edits_by_asset: Dict[str, List[Dict[str, Any]]] = {} + for item in list(suggested_field_edits or []): + path = str(item.get("path") or "") + asset_type = _asset_type_from_path(path) + if not asset_type: + continue + edits_by_asset.setdefault(asset_type, []).append(dict(item)) + actions = [dict(item or {}) for item in list(suggested_actions or [])] + if "chapter_task_coupling" in list(bundle_payload.get("asset_sequence") or []) and "chapter_task_coupling" not in target_by_type: + target_by_type["chapter_task_coupling"] = { + "asset_type": "chapter_task_coupling", + "asset_label": "章节任务耦合", + "validation_panel": "task_linking", + "validation_panel_label": "Task Linking", + "target_label": ",".join(str(item) for item in list(targeted_chapter_indices or [])[:6]), + } + step_planning = _bundle_step_planning( + bundle_id=bundle_id, + bundle_label=str(bundle_payload.get("label") or bundle_id), + asset_sequence=list(bundle_payload.get("asset_sequence") or []), + target_by_type=target_by_type, + edits_by_asset=edits_by_asset, + validation_sequence=list(bundle_payload.get("validation_sequence") or []), + ) + rerun_attribution = _rerun_attribution_payload( + window_label=window_label, + success_metrics=list(bundle_payload.get("success_metrics") or []), + ) + stop_condition = _stop_condition_payload(str(bundle_payload.get("stop_condition") or "")) + return { + "strategy_bundle_id": bundle_id, + "strategy_bundle_label": str(bundle_payload.get("label") or bundle_id), + "window_label": window_label, + "issue_codes": list(dict.fromkeys(str(item) for item in issue_codes if str(item))), + "asset_sequence": list(bundle_payload.get("asset_sequence") or []), + "validation_sequence": list(bundle_payload.get("validation_sequence") or []), + "success_metrics": list(bundle_payload.get("success_metrics") or []), + "stop_condition": stop_condition, + "steps": step_planning, + "bundle_step_planning": step_planning, + "step_level_apply_order": [dict(item) for item in step_planning], + "rerun_attribution": rerun_attribution, + "execution_protocol_enabled": True, + "suggested_actions": actions, + "config_version": str(config.get("config_version") or ""), + } + + +def infer_strategy_bundles_for_diagnostic(diagnostic: Dict[str, Any]) -> List[Dict[str, Any]]: + window_breach_attribution = [dict(item or {}) for item in list(diagnostic.get("window_breach_attribution") or [])] + issue_codes = [str(item.get("issue_code") or "") for item in list(diagnostic.get("issue_category_distribution") or []) if str(item.get("issue_code") or "")] + bundles: List[Dict[str, Any]] = [] + seen: set[str] = set() + if window_breach_attribution: + for item in window_breach_attribution: + bundle_id = _bundle_id_for_issue_codes(list(item.get("issue_codes") or [])) + if not bundle_id or bundle_id in seen: + continue + seen.add(bundle_id) + bundle = build_strategy_bundle( + issue_codes=list(item.get("issue_codes") or []), + window_label=str(item.get("window_label") or ""), + primary_asset_target={ + "asset_type": item.get("asset"), + "asset_label": item.get("asset"), + "validation_panel": "compare" if item.get("asset") != "chapter_tasks" else "task_linking", + "validation_panel_label": "Compare" if item.get("asset") != "chapter_tasks" else "Task Linking", + "target_label": item.get("asset"), + }, + secondary_asset_targets=[], + suggested_actions=[], + suggested_field_edits=[], + targeted_chapter_indices=[], + ) + if bundle: + bundle["world_id"] = diagnostic.get("world_id", "") + bundles.append(bundle) + for issue_code in issue_codes: + bundle_id = _bundle_id_for_issue_codes([issue_code]) + if not bundle_id or bundle_id in seen: + continue + seen.add(bundle_id) + bundle = build_strategy_bundle( + issue_codes=[issue_code], + window_label="general", + primary_asset_target={"asset_type": "", "target_label": ""}, + secondary_asset_targets=[], + suggested_actions=[], + suggested_field_edits=[], + targeted_chapter_indices=[], + ) + if bundle: + bundle["world_id"] = diagnostic.get("world_id", "") + bundles.append(bundle) + return bundles + + +def build_strategy_validation_summary(weakest_pack_diagnostics: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + grouped: Dict[str, Dict[str, Any]] = {} + for diagnostic in list(weakest_pack_diagnostics or []): + for bundle in infer_strategy_bundles_for_diagnostic(dict(diagnostic or {})): + bundle_id = str(bundle.get("strategy_bundle_id") or "") + if not bundle_id: + continue + entry = grouped.setdefault( + bundle_id, + { + "strategy_bundle_id": bundle_id, + "strategy_bundle_label": bundle.get("strategy_bundle_label", bundle_id), + "world_ids": [], + "window_labels": [], + "success_metrics": list(bundle.get("success_metrics") or []), + }, + ) + world_id = str(bundle.get("world_id") or "") + if world_id and world_id not in entry["world_ids"]: + entry["world_ids"].append(world_id) + window_label = str(bundle.get("window_label") or "") + if window_label and window_label not in entry["window_labels"]: + entry["window_labels"].append(window_label) + return { + "available": bool(grouped), + "bundle_groups": sorted(grouped.values(), key=lambda item: (-len(item["world_ids"]), item["strategy_bundle_id"])), + "bundle_count": len(grouped), + } diff --git a/src/narrativeos/content_quality_strategy_execution.py b/src/narrativeos/content_quality_strategy_execution.py new file mode 100644 index 0000000..a31f731 --- /dev/null +++ b/src/narrativeos/content_quality_strategy_execution.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +import copy +import json +from collections import Counter +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Sequence +from uuid import uuid4 + + +def execute_strategy_bundle_protocol( + *, + worldpack_payload: Dict[str, Any], + baseline_simulation_report: Dict[str, Any], + campaign: Dict[str, Any], + strategy_bundle: Dict[str, Any], + execution_mode: str, + simulation_runner: Callable[[Dict[str, Any]], Dict[str, Any]], + apply_step: Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]], + build_result_attribution: Callable[..., Dict[str, Any]], + build_stop_decision: Callable[..., Dict[str, Any]], + prior_executions: Sequence[Dict[str, Any]] | None = None, +) -> Dict[str, Any]: + mutated_worldpack_payload = copy.deepcopy(worldpack_payload) + step_plan = sorted( + [dict(item or {}) for item in list(strategy_bundle.get("step_level_apply_order") or [])], + key=lambda item: int(item.get("apply_order", 0) or 0), + ) + step_receipts = [apply_step(mutated_worldpack_payload, step) for step in step_plan] + rerun_report = copy.deepcopy(simulation_runner(mutated_worldpack_payload)) + latest_repair_loop_outcome = dict(rerun_report.get("latest_repair_loop_outcome") or {}) + result_attribution = build_result_attribution( + strategy_bundle=strategy_bundle, + baseline_report=baseline_simulation_report, + rerun_report=rerun_report, + step_receipts=step_receipts, + latest_repair_loop_outcome=latest_repair_loop_outcome, + ) + stop_decision = build_stop_decision( + stop_condition=dict(strategy_bundle.get("stop_condition") or {}), + result_attribution=result_attribution, + prior_executions=[dict(item or {}) for item in list(prior_executions or [])], + latest_repair_loop_outcome=latest_repair_loop_outcome, + ) + return { + "execution_id": f"bundle_exec_{uuid4().hex[:10]}", + "executed_at": datetime.now(timezone.utc).isoformat(), + "execution_mode": execution_mode, + "campaign_id": str(campaign.get("campaign_id") or ""), + "strategy_bundle_id": str(strategy_bundle.get("strategy_bundle_id") or ""), + "strategy_bundle_label": str( + strategy_bundle.get("strategy_bundle_label") + or strategy_bundle.get("label") + or strategy_bundle.get("strategy_bundle_id") + or "" + ), + "window_label": str(campaign.get("window_label") or ""), + "issue_code": str(campaign.get("issue_code") or ""), + "issue_codes": list( + dict.fromkeys( + str(item) + for item in list(strategy_bundle.get("issue_codes") or [campaign.get("issue_code")]) + if str(item) + ) + ), + "bundle_step_planning": step_plan, + "step_level_apply_order": step_plan, + "step_level_apply_receipt": step_receipts, + "applied_step_count": sum(1 for item in step_receipts if str(item.get("status") or "") == "applied"), + "applied_edit_count": sum(int(item.get("applied_edit_count", 0) or 0) for item in step_receipts), + "rerun_attribution": dict(strategy_bundle.get("rerun_attribution") or {}), + "result_attribution": result_attribution, + "stop_condition": dict(strategy_bundle.get("stop_condition") or {}), + "stop_decision": stop_decision, + "repair_loop_outcome": { + "ready_for_validation": bool(latest_repair_loop_outcome.get("ready_for_validation", False)), + "severity_trend": str(latest_repair_loop_outcome.get("severity_trend") or ""), + "window_label": str(latest_repair_loop_outcome.get("window_label") or ""), + "window_breach_kind": str(latest_repair_loop_outcome.get("window_breach_kind") or ""), + "baseline_window_issue_count": int(latest_repair_loop_outcome.get("baseline_window_issue_count", 0) or 0), + "current_window_issue_count": int(latest_repair_loop_outcome.get("current_window_issue_count", 0) or 0), + }, + "execution_status": "completed", + "mutated_worldpack_payload": mutated_worldpack_payload, + "rerun_report": rerun_report, + } + + +def build_step_level_apply_summary(step_receipts: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + status_counts = Counter() + asset_type_counts = Counter() + operation_counts = Counter() + applied_step_count = 0 + applied_edit_count = 0 + for step in list(step_receipts or []): + status = str(step.get("status") or "") + asset_type = str(step.get("asset_type") or "") + if status: + status_counts[status] += 1 + if asset_type: + asset_type_counts[asset_type] += 1 + if status == "applied": + applied_step_count += 1 + applied_edit_count += int(step.get("applied_edit_count", 0) or 0) + for edit in list(step.get("edit_receipts") or []): + operation = str(edit.get("operation") or "") + if operation: + operation_counts[operation] += 1 + return { + "step_status_counts": dict(status_counts), + "asset_type_counts": dict(asset_type_counts), + "operation_counts": dict(operation_counts), + "applied_step_count": applied_step_count, + "applied_edit_count": applied_edit_count, + } + + +def _top_counter_items(counter: Counter, *, limit: int = 3) -> List[Dict[str, Any]]: + return [ + {"name": name, "count": int(count)} + for name, count in counter.most_common(limit) + if str(name) + ] + + +def build_strategy_bundle_batch_validation_summary( + *, + strategy_bundle_id: str, + strategy_bundle_label: str, + batch_execution_mode: str, + benchmark_mode: str, + chapter_budget: int, + weakest_source_world_ids: Sequence[str], + compatible_world_ids: Sequence[str], + skipped_worlds: Sequence[Dict[str, Any]], + validated_worlds: Sequence[Dict[str, Any]], +) -> Dict[str, Any]: + validated = [dict(item or {}) for item in list(validated_worlds or [])] + skipped = [dict(item or {}) for item in list(skipped_worlds or [])] + aggregated_step_statuses = Counter() + aggregated_asset_types = Counter() + aggregated_operations = Counter() + aggregated_result_statuses = Counter() + improved_metric_counts = Counter() + regressed_metric_counts = Counter() + flat_metric_counts = Counter() + stop_decision_counts = Counter() + adaptation_metric_counts = Counter() + adaptation_asset_counts = Counter() + ready_for_validation_count = 0 + effective_count = 0 + + for world in validated: + step_summary = dict(world.get("step_receipt_summary") or {}) + result_attribution = dict(world.get("result_attribution") or {}) + stop_decision = dict(world.get("stop_decision") or {}) + for name, count in dict(step_summary.get("step_status_counts") or {}).items(): + aggregated_step_statuses[str(name)] += int(count or 0) + for name, count in dict(step_summary.get("asset_type_counts") or {}).items(): + aggregated_asset_types[str(name)] += int(count or 0) + for name, count in dict(step_summary.get("operation_counts") or {}).items(): + aggregated_operations[str(name)] += int(count or 0) + overall_status = str(result_attribution.get("overall_status") or "") + if overall_status: + aggregated_result_statuses[overall_status] += 1 + for metric_name in list(result_attribution.get("improved_metrics") or []): + improved_metric_counts[str(metric_name)] += 1 + for metric_name in list(result_attribution.get("regressed_metrics") or []): + regressed_metric_counts[str(metric_name)] += 1 + adaptation_metric_counts[str(metric_name)] += 1 + for metric_name in list(result_attribution.get("flat_metrics") or []): + flat_metric_counts[str(metric_name)] += 1 + stop_name = str(stop_decision.get("decision") or "") + if stop_name: + stop_decision_counts[stop_name] += 1 + if bool(world.get("ready_for_validation")): + ready_for_validation_count += 1 + if overall_status == "improved" or bool(world.get("ready_for_validation")): + effective_count += 1 + for step in list(world.get("step_level_apply_receipt") or []): + step_status = str(step.get("status") or "") + asset_type = str(step.get("asset_type") or "") + if step_status in {"noop", "skipped"} and asset_type: + adaptation_asset_counts[asset_type] += 1 + + validated_world_count = len(validated) + effectiveness_rate = round( + effective_count / float(max(1, validated_world_count)), + 3, + ) if validated_world_count else 0.0 + regressed_count = int(aggregated_result_statuses.get("regressed", 0)) + escalate_count = int(stop_decision_counts.get("escalate", 0)) + + if validated_world_count == 0: + decision = "" + decision_reason = "no_compatible_weakest_packs" + available = False + elif ( + validated_world_count >= 2 + and effectiveness_rate >= 0.67 + and (regressed_count / float(validated_world_count)) < 0.34 + and (escalate_count / float(validated_world_count)) < 0.34 + ): + decision = "continue" + decision_reason = "bundle_effective_across_weakest_packs" + available = True + elif ( + validated_world_count >= 2 + and ( + effectiveness_rate < 0.34 + or (regressed_count / float(validated_world_count)) >= 0.5 + ) + ): + decision = "retire" + decision_reason = "bundle_low_effectiveness_or_high_regression" + available = True + else: + decision = "adapt" + decision_reason = "bundle_mixed_signal_requires_adjustment" + available = True + + adaptation_targets: List[Dict[str, Any]] = [] + for item in _top_counter_items(adaptation_metric_counts): + adaptation_targets.append({"kind": "metric", **item}) + for item in _top_counter_items(adaptation_asset_counts): + adaptation_targets.append({"kind": "asset_step", **item}) + + return { + "available": available, + "strategy_bundle_id": strategy_bundle_id, + "strategy_bundle_label": strategy_bundle_label, + "batch_execution_mode": batch_execution_mode, + "benchmark_mode": benchmark_mode, + "chapter_budget": int(chapter_budget or 0), + "weakest_source_world_ids": list(weakest_source_world_ids or []), + "compatible_world_ids": list(compatible_world_ids or []), + "skipped_worlds": skipped, + "validated_world_count": validated_world_count, + "validated_worlds": validated, + "aggregated_step_receipts": { + "step_status_counts": dict(aggregated_step_statuses), + "asset_type_counts": dict(aggregated_asset_types), + "operation_counts": dict(aggregated_operations), + "applied_step_count": sum(int(item.get("step_receipt_summary", {}).get("applied_step_count", 0) or 0) for item in validated), + "applied_edit_count": sum(int(item.get("step_receipt_summary", {}).get("applied_edit_count", 0) or 0) for item in validated), + }, + "aggregated_result_attribution": { + "overall_status_counts": dict(aggregated_result_statuses), + "improved_metric_counts": dict(improved_metric_counts), + "regressed_metric_counts": dict(regressed_metric_counts), + "flat_metric_counts": dict(flat_metric_counts), + "stop_decision_counts": dict(stop_decision_counts), + "ready_for_validation_count": ready_for_validation_count, + }, + "effectiveness_rate": effectiveness_rate, + "decision": decision, + "decision_reason": decision_reason, + "adaptation_targets": adaptation_targets[:6], + } + + +def _safe_float(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _parse_history_notes(notes: Any) -> Dict[str, Any]: + if isinstance(notes, dict): + return dict(notes) + if not isinstance(notes, str) or not notes: + return {} + try: + payload = json.loads(notes) + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + +def _strategy_bundle_history_note_payload(batch_validation: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(batch_validation or {}) + return { + "generated_at": str(payload.get("generated_at") or datetime.now(timezone.utc).isoformat()), + "strategy_bundle_id": str(payload.get("strategy_bundle_id") or ""), + "strategy_bundle_label": str(payload.get("strategy_bundle_label") or ""), + "benchmark_mode": str(payload.get("benchmark_mode") or ""), + "chapter_budget": int(payload.get("chapter_budget", 0) or 0), + "weakest_source_world_ids": [str(item) for item in list(payload.get("weakest_source_world_ids") or []) if str(item)], + "compatible_world_ids": [str(item) for item in list(payload.get("compatible_world_ids") or []) if str(item)], + "validated_world_count": int(payload.get("validated_world_count", 0) or 0), + "effectiveness_rate": _safe_float(payload.get("effectiveness_rate")), + "decision": str(payload.get("decision") or ""), + "decision_reason": str(payload.get("decision_reason") or ""), + "aggregated_result_attribution": { + "overall_status_counts": dict( + dict(payload.get("aggregated_result_attribution") or {}).get("overall_status_counts") or {} + ), + "stop_decision_counts": dict( + dict(payload.get("aggregated_result_attribution") or {}).get("stop_decision_counts") or {} + ), + }, + "adaptation_targets": [dict(item or {}) for item in list(payload.get("adaptation_targets") or [])[:6]], + } + + +def record_strategy_bundle_batch_validation_run( + *, + repository: Any, + batch_validation: Dict[str, Any], + reviewer_id: str = "system_batch_validator", +) -> Dict[str, Any]: + payload = _strategy_bundle_history_note_payload(batch_validation) + strategy_bundle_id = str(payload.get("strategy_bundle_id") or "") + if not strategy_bundle_id: + return {} + decision = str(payload.get("decision") or "").strip() or "not_run" + return repository.save_review_record( + { + "asset_type": "strategy_bundle_batch_validation", + "asset_id": strategy_bundle_id, + "status": decision, + "reviewer_id": reviewer_id, + "notes": json.dumps(payload, ensure_ascii=False), + } + ) + + +def list_strategy_bundle_batch_validation_history( + *, + repository: Any, + strategy_bundle_id: str, + limit: int = 5, +) -> Dict[str, Any]: + if not str(strategy_bundle_id or "").strip(): + return { + "available": False, + "strategy_bundle_id": "", + "entry_count": 0, + "entries": [], + } + rows = list( + repository.list_review_records( + asset_type="strategy_bundle_batch_validation", + asset_id=strategy_bundle_id, + ) + or [] + ) + entries: List[Dict[str, Any]] = [] + for row in rows[: max(1, int(limit or 5))]: + notes_payload = _parse_history_notes(row.get("notes")) + decision = str(notes_payload.get("decision") or row.get("status") or "") + entries.append( + { + "review_id": row.get("review_id"), + "generated_at": str(notes_payload.get("generated_at") or row.get("updated_at") or ""), + "strategy_bundle_id": str(notes_payload.get("strategy_bundle_id") or strategy_bundle_id), + "strategy_bundle_label": str(notes_payload.get("strategy_bundle_label") or ""), + "benchmark_mode": str(notes_payload.get("benchmark_mode") or ""), + "chapter_budget": int(notes_payload.get("chapter_budget", 0) or 0), + "validated_world_count": int(notes_payload.get("validated_world_count", 0) or 0), + "effectiveness_rate": _safe_float(notes_payload.get("effectiveness_rate")), + "decision": decision, + "decision_reason": str(notes_payload.get("decision_reason") or ""), + "compatible_world_ids": [ + str(item) for item in list(notes_payload.get("compatible_world_ids") or []) if str(item) + ], + "top_adaptation_targets": [ + dict(item or {}) for item in list(notes_payload.get("adaptation_targets") or [])[:3] + ], + } + ) + return { + "available": bool(entries), + "strategy_bundle_id": str(strategy_bundle_id), + "entry_count": len(entries), + "entries": entries, + } + + +def build_strategy_bundle_batch_validation_trend( + history_payload: Dict[str, Any], +) -> Dict[str, Any]: + history = dict(history_payload or {}) + entries = [dict(item or {}) for item in list(history.get("entries") or [])] + comparable_entries = [item for item in entries if str(item.get("decision") or "") != "not_run"] + if not entries: + return { + "available": False, + "strategy_bundle_id": str(history.get("strategy_bundle_id") or ""), + "recent_run_count": 0, + "latest_decision": "", + "latest_effectiveness_rate": 0.0, + "previous_effectiveness_rate": 0.0, + "delta_effectiveness_rate": 0.0, + "trend_status": "insufficient_history", + "trend_reason": "no_saved_batch_validation_runs", + "retire_recommended": False, + } + latest_entry = dict(comparable_entries[0] if comparable_entries else entries[0]) + previous_entry = dict(comparable_entries[1] if len(comparable_entries) > 1 else {}) + latest_decision = str(latest_entry.get("decision") or "") + latest_effectiveness_rate = _safe_float(latest_entry.get("effectiveness_rate")) + previous_effectiveness_rate = _safe_float(previous_entry.get("effectiveness_rate")) + delta_effectiveness_rate = round(latest_effectiveness_rate - previous_effectiveness_rate, 3) + recent_run_count = len(comparable_entries) + if latest_decision == "retire" or ( + len(comparable_entries) >= 2 + and str(comparable_entries[0].get("decision") or "") == "retire" + and str(comparable_entries[1].get("decision") or "") == "retire" + ): + trend_status = "retire_watch" + trend_reason = "latest_or_recent_runs_recommend_retire" + elif recent_run_count < 2: + trend_status = "insufficient_history" + trend_reason = "fewer_than_two_comparable_runs" + elif delta_effectiveness_rate >= 0.10: + trend_status = "improving" + trend_reason = "effectiveness_rate_up_by_0_10_or_more" + elif delta_effectiveness_rate <= -0.10: + trend_status = "deteriorating" + trend_reason = "effectiveness_rate_down_by_0_10_or_more" + else: + trend_status = "flat" + trend_reason = "effectiveness_rate_change_within_flat_band" + recent_three = comparable_entries[:3] + retire_recommended = bool( + trend_status == "retire_watch" + or ( + recent_three + and round( + sum(_safe_float(item.get("effectiveness_rate")) for item in recent_three) + / float(len(recent_three)), + 3, + ) < 0.34 + and latest_decision in {"adapt", "retire"} + ) + ) + return { + "available": True, + "strategy_bundle_id": str(history.get("strategy_bundle_id") or latest_entry.get("strategy_bundle_id") or ""), + "recent_run_count": recent_run_count, + "latest_decision": latest_decision, + "latest_effectiveness_rate": latest_effectiveness_rate, + "previous_effectiveness_rate": previous_effectiveness_rate, + "delta_effectiveness_rate": delta_effectiveness_rate, + "trend_status": trend_status, + "trend_reason": trend_reason, + "retire_recommended": retire_recommended, + } diff --git a/src/narrativeos/core/dialogue.py b/src/narrativeos/core/dialogue.py index 729af57..7531ef2 100644 --- a/src/narrativeos/core/dialogue.py +++ b/src/narrativeos/core/dialogue.py @@ -23,17 +23,222 @@ def _attach_reaction(counterpart: str, reaction: str) -> str: return f"{counterpart}{reaction}" +def _dialogue_seed(state_before: NarrativeState, beat: SceneBeat, *, extra: int = 0) -> int: + event = beat.event + event_id = str(getattr(event, "event_id", "") or "") + scene_function = str(getattr(event, "scene_function", "") or "") + dramatic_job = str(getattr(beat, "dramatic_job", "") or "") + actor_seed = ":".join(str(actor_id) for actor_id in getattr(event, "actors", []) or []) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + beat_index = int(getattr(beat, "beat_index", 0) or 0) + raw_seed = f"{event_id}:{scene_function}:{dramatic_job}:{actor_seed}" + return sum(ord(char) for char in raw_seed) + chapter_index * 41 + beat_index * 17 + int(extra) + + +def _frame_variant(frames: dict[str, list[str]], beat_key: str, *, index: int) -> str: + variants = frames.get(beat_key) or frames.get("pressure") or [] + return variants[index % len(variants)] if variants else "{speaker}低声道:“{line}”" + + +def _scene_label(scene_function: str) -> str: + labels = { + "false_peace": "表面平静", + "temptation": "试探", + "truth_trial": "真相逼近", + "mask_crack": "裂口", + "confession_window": "真话窗口", + "debt_exchange": "旧账回潮", + "karma_ripening": "因果回响", + "humiliation": "难堪代价", + "vow_payment": "誓言偿付", + "misrecognition": "误解升级", + "mercy_vs_control": "庇护与控制", + } + return labels.get(scene_function, scene_function.replace("_", " ")) + + +def _chapter_line_variant(line: str, state_before: NarrativeState, beat: SceneBeat, *, role: str, index: int) -> str: + scene_label = _scene_label(str(getattr(beat.event, "scene_function", "") or "")) + location = str(getattr(beat.event, "location", "") or "这里") + marker_pool = ["案角", "门影", "杯沿", "窗纸", "衣袖", "灯芯", "阶前风", "纸页声"] + marker = marker_pool[index % len(marker_pool)] + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + if chapter_index < 12: + return line + if "半句" in line or "说完整" in line: + variants = [ + f"把后半句也放到{marker}旁,别再留给沉默替你收。", + f"这一步{scene_label}已经露出来了,别只把最轻的那层递给我。", + f"既然走到{location}了,就把真正会疼的那句也说出来。", + f"别让{marker}都替你认了,你自己却还退在半步外。", + f"我听的不是开头,是你肯不肯把后果一起交出来。", + ] + return variants[index % len(variants)] + if "局势" in line or "认下" in line or "算在我头上" in line or "不再推" in line: + variants = [ + f"这回我先接住{marker}边那层后果,别的难看也不往外推。", + f"{scene_label}已经压到眼前,我就从这一步开始自己承担。", + f"我把这句放在{location}里,后面的账也由我亲手补上。", + f"该疼的地方我不躲了,先从{marker}旁这一句算起。", + f"这一步我不再借别人收场,剩下的也该我自己走完。", + ] + return variants[index % len(variants)] + if "案角纸页都响了" in line or "不往回收" in line: + variants = [ + f"{marker}都已经响了,我就不再把这句话退回去。", + f"风声把{scene_label}推到这里,我也该把话落实。", + f"既然{location}都听见了,我不会再把它装成玩笑。", + f"这一下已经照到{marker}上,我就不往暗处藏了。", + ] + return variants[index % len(variants)] + return line + + +def _repeated_dialogue_closer(speaker: str, counterpart: str, *, index: int) -> str: + variants = [ + "两人都知道,话已经绕不过刚才留下的那层意思了。", + f"{speaker}没有再把目光移开,{counterpart}也没有替这句真话找台阶。", + f"{counterpart}把沉默压住时,{speaker}终于明白后半句已经不能再拖到下一次。", + f"那一下停顿落在两人之间,比任何圆场都更像一次逼近。", + f"{speaker}和{counterpart}都听见了同一层余波,只是谁也没再把它说轻。", + f"{counterpart}先收住呼吸,{speaker}便知道这一次不能再用上一句回答接过去。", + f"桌沿那点轻响替两人停了半拍,随后谁都没有把后果推回沉默里。", + f"{speaker}把半步退路收回来时,{counterpart}眼里的迟疑也换了方向。", + f"这一次留在两人之间的不是圆场,而是下一句必须换法说出的压力。", + ] + return variants[index % len(variants)] + + +def compose_late_longform_compact_exchange( + world: WorldBible, + state_before: NarrativeState, + beat: SceneBeat, + *, + repeated: bool, + variant_offset: int = 0, +) -> str: + if len(beat.event.actors) < 2: + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + seed = _dialogue_seed(state_before, beat, extra=variant_offset) + lines = [ + "这回我不再绕开,先把后果接住。", + "我换一种做法,不让旧话再拖长。", + "这一步我往前走,剩下的也照实认。", + "别让沉默替我收场,我自己开口。", + ] + actions = [ + f"{actor_name}按住案角,停了一息才低声道:“{lines[seed % len(lines)]}”", + f"{actor_name}把袖口收紧,抬眼道:“{lines[(seed + 1) % len(lines)]}”", + f"{actor_name}往前半步,声音压稳:“{lines[(seed + 2) % len(lines)]}”", + ] + return actions[seed % len(actions)] + + speaker_id = beat.event.actors[0] + counterpart_id = beat.event.actors[1] + speaker = _actor_name(state_before, speaker_id) + counterpart = _actor_name(state_before, counterpart_id) + speaker_voice = voice_profile_for_actor(world, state_before, speaker_id) + counterpart_response = response_profile_for_actor(world, state_before, counterpart_id) + beat_key = beat.dramatic_job + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + seed = _dialogue_seed(state_before, beat, extra=variant_offset + chapter_index * 3) + marker_pool = ["案角", "门影", "杯沿", "窗纸", "衣袖", "灯芯", "阶前风", "纸页声", "门框", "桌沿"] + marker = marker_pool[seed % len(marker_pool)] + location = str(getattr(beat.event, "location", "") or "这里") + scene_label = _scene_label(str(getattr(beat.event, "scene_function", "") or "")) + speaker_line = _line_from_profile( + getattr(speaker_voice, { + "entry": "opening_style", + "pressure": "pressure_style", + "pivot": "pivot_style", + "aftermath": "aftermath_style", + "echo": "echo_style", + }.get(beat_key, "pressure_style")), + beat.event.title, + index=seed + 3, + ) + reply = _line_from_profile( + counterpart_response.reply_lines.get(beat_key, []), + "那就把后果说实。", + index=seed + 11, + ) + followup = _line_from_profile( + speaker_voice.signature_replies, + "我现在往前走,不再把这句留给沉默。", + index=seed + 19, + ) + speaker_line = _chapter_line_variant(speaker_line, state_before, beat, role="speaker", index=seed + 5) + reply = _chapter_line_variant(reply, state_before, beat, role="reply", index=seed + 7) + followup = _chapter_line_variant(followup, state_before, beat, role="followup", index=seed + 13) + counter_closers = [ + f"那就别退,先从{marker}旁这一句开始。", + f"我听见了,也会看你接下来怎么做。", + f"{scene_label}已经在眼前,你别再把它说轻。", + f"{location}都听见了,这次别只留下半步。", + f"把这句放稳,后面的账才有地方落。", + ] + action_frames = [ + f"{speaker}按住{marker},抬眼看向{counterpart}:“{speaker_line}”", + f"{speaker}把手从{marker}边收回,往前半步:“{speaker_line}”", + f"{speaker}停在{location}的灯影里,声音压低:“{speaker_line}”", + f"{speaker}握紧袖口,没有退开:“{speaker_line}”", + ] + response_frames = [ + f"{counterpart}没有替他圆场:“{reply}”", + f"{counterpart}把视线压回去:“{reply}”", + f"{counterpart}看着{marker},回得很短:“{reply}”", + f"{counterpart}往前一步,声音更稳:“{reply}”", + ] + follow_frames = [ + f"{speaker}点了一下头:“{followup}”", + f"{speaker}把呼吸压稳:“{followup}”", + f"{speaker}没有再借沉默避开:“{followup}”", + f"{speaker}把掌心贴上桌沿:“{followup}”", + ] + close_frames = [ + f"{counterpart}停了半息:“{counter_closers[(seed + 1) % len(counter_closers)]}”", + f"{counterpart}收住脚步:“{counter_closers[(seed + 2) % len(counter_closers)]}”", + f"{counterpart}把路让出半寸:“{counter_closers[(seed + 3) % len(counter_closers)]}”", + f"{counterpart}看着他:“{counter_closers[(seed + 4) % len(counter_closers)]}”", + ] + frame_window = chapter_index // 20 + int(variant_offset) + parts = [ + action_frames[(seed + frame_window) % len(action_frames)], + response_frames[(seed // 3 + frame_window) % len(response_frames)], + follow_frames[(seed // 5 + frame_window) % len(follow_frames)], + close_frames[(seed // 7 + frame_window) % len(close_frames)], + ] + if repeated: + parts.append(_repeated_dialogue_closer(speaker, counterpart, index=seed + 5)) + return " ".join(parts) + + def compose_dialogue(world: WorldBible, state_before: NarrativeState, beat: SceneBeat, *, repeated: bool) -> str: + seed = _dialogue_seed(state_before, beat) if len(beat.event.actors) < 2: actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" - reflection = ( - "我先把这句话留在这里,等下一次开口时,再看看它会不会逼得人没有退路。" + reflections = ( + [ + "我先把这句话留在这里,等下一次开口时,再看看它会不会逼得人没有退路。", + "这一回我先把话落稳,后面的路再难,也不能只靠退让走完。", + "如果这一步已经照到眼前,我就不能再把它塞回心里。", + ] if not repeated - else "这句心里话已经绕不回去了,真要再装作没发生,反而更显得心虚。" + else [ + "这句心里话已经绕不回去了,真要再装作没发生,反而更显得心虚。", + "不能再照上一回的沉默走了,换一种说法,才算真的往前。", + "我先把这一步认清,后面的代价不能总留给下一次。", + ] ) + reflection = reflections[seed % len(reflections)] + action_frames = [ + f"{actor_name}没有立刻把心思遮回去,只让那口气在胸口多压了一瞬。", + f"{actor_name}把指尖从衣袖里收回来,像先按住了一个快要出口的退路。", + f"{actor_name}偏头看向灯影外那一点空处,呼吸比方才慢了半拍。", + ] return " ".join( [ - f"{actor_name}没有立刻把心思遮回去,只让那口气在胸口多压了一瞬。", + action_frames[(seed // 5) % len(action_frames)], f"{actor_name}低声道:“{reflection}”", ] ) @@ -42,10 +247,20 @@ def compose_dialogue(world: WorldBible, state_before: NarrativeState, beat: Scen counterpart_id = beat.event.actors[1] if speaker_id == counterpart_id: actor_name = _actor_name(state_before, speaker_id) + self_lines = [ + "真正难的不是看见这一层心思,而是看见以后还得继续往前走。", + "这一步既然已经露出来,就不能再靠同一个借口绕回去。", + "我要换一种走法,否则说再多也只是把旧账拖长。", + ] + action_lines = [ + f"{actor_name}抬眼看向空下来的那一处,像是在替自己把那句真话一点点逼出来。", + f"{actor_name}把手按在桌沿上,听见那点细响以后才慢慢开口。", + f"{actor_name}没有往后退,只让目光从灯影边缘重新落回眼前。", + ] return " ".join( [ - f"{actor_name}抬眼看向空下来的那一处,像是在替自己把那句真话一点点逼出来。", - f"{actor_name}低声道:“真正难的不是看见这一层心思,而是看见以后还得继续往前走。”", + action_lines[(seed // 3) % len(action_lines)], + f"{actor_name}低声道:“{self_lines[seed % len(self_lines)]}”", ] ) speaker = _actor_name(state_before, speaker_id) @@ -56,7 +271,9 @@ def compose_dialogue(world: WorldBible, state_before: NarrativeState, beat: Scen beat_key = beat.dramatic_job beat_index = getattr(beat, "beat_index", 0) - variant_index = beat_index + int(getattr(state_before, "chapter_index", 0)) + event_seed = sum(ord(char) for char in str(getattr(beat.event, "event_id", "") or "")) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + variant_index = _dialogue_seed(state_before, beat, extra=event_seed + beat_index) speaker_line = _line_from_profile( getattr(speaker_voice, { "entry": "opening_style", @@ -66,17 +283,17 @@ def compose_dialogue(world: WorldBible, state_before: NarrativeState, beat: Scen "echo": "echo_style", }.get(beat_key, "pressure_style")), beat.event.title, - index=variant_index, + index=variant_index + 3, ) reaction = _line_from_profile( counterpart_response.reaction_lines.get(beat_key, []), "他没有立刻回话,只让沉默先压了一层上来。", - index=variant_index, + index=variant_index + 11, ) reply = _line_from_profile( counterpart_response.reply_lines.get(beat_key, []), "你总得先把心里的话说完整。", - index=variant_index, + index=variant_index + 19, ) followup = _line_from_profile( speaker_voice.signature_replies, @@ -107,31 +324,150 @@ def compose_dialogue(world: WorldBible, state_before: NarrativeState, beat: Scen "这层意思先留在这里,后面我会把它说得更完整。", ], }.get(beat_key, ["这条路到了这里,已经不能再装作没发生。"]), - index=variant_index, + index=variant_index + 29, + ) + speaker_line = _chapter_line_variant( + speaker_line, + state_before, + beat, + role="speaker", + index=variant_index + chapter_index // 13, + ) + reply = _chapter_line_variant( + reply, + state_before, + beat, + role="reply", + index=variant_index + chapter_index // 17 + 5, + ) + followup = _chapter_line_variant( + followup, + state_before, + beat, + role="followup", + index=variant_index + chapter_index // 19 + 11, ) - opener = { - "entry": f"{speaker}看了{counterpart}一眼,低声道:“{speaker_line}”", - "pressure": f"{speaker}把声音压得更低,对{counterpart}说道:“{speaker_line}”", - "pivot": f"{speaker}终于抬眼迎上{counterpart}的视线:“{speaker_line}”", - "aftermath": f"{speaker}隔了半息,才又对{counterpart}开口:“{speaker_line}”", - "echo": f"临散前,{speaker}还是朝{counterpart}补了一句:“{speaker_line}”", - }.get(beat_key, f"{speaker}看了{counterpart}一眼,低声道:“{speaker_line}”") + opener_frames = { + "entry": [ + "{speaker}看了{counterpart}一眼,低声道:“{line}”", + "{speaker}先把目光落到{counterpart}袖边,才开口:“{line}”", + "{speaker}停在门影旁,没有寒暄,只把话递过去:“{line}”", + "{speaker}把指节从案边收回,声音压稳:“{line}”", + ], + "pressure": [ + "{speaker}把声音压得更低,对{counterpart}说道:“{line}”", + "{speaker}没有退开,顺着那点停顿逼近一句:“{line}”", + "{speaker}盯住{counterpart}的眼神,把话落得很慢:“{line}”", + "{speaker}先按住呼吸,再把最难听的一句送出来:“{line}”", + ], + "pivot": [ + "{speaker}终于抬眼迎上{counterpart}的视线:“{line}”", + "{speaker}往前半步,像把岔口也一起推到明处:“{line}”", + "{speaker}没有再接旧话,直接换了方向:“{line}”", + "{speaker}把原本要咽回去的那句改成了选择:“{line}”", + ], + "aftermath": [ + "{speaker}隔了半息,才又对{counterpart}开口:“{line}”", + "{speaker}听见余声落下,才把后果接上:“{line}”", + "{speaker}没有替自己收场,只低声补了一句:“{line}”", + "{speaker}把气息压稳以后,终于认下这句:“{line}”", + ], + "echo": [ + "临散前,{speaker}还是朝{counterpart}补了一句:“{line}”", + "{speaker}走出半步又停住,回身道:“{line}”", + "{speaker}没有让余音自己散掉,反而重新开口:“{line}”", + "{speaker}在门边停住,把回声换成一句实话:“{line}”", + ], + } + opener = _frame_variant(opener_frames, beat_key, index=variant_index + chapter_index // 20).format( + speaker=speaker, + counterpart=counterpart, + line=speaker_line, + ) response = _attach_reaction(counterpart, reaction) - close = { - "entry": f"{counterpart}最后只回了一句:“{reply}”", - "pressure": f"{counterpart}把话压得很低,只往前送了一句:“{reply}”", - "pivot": f"{counterpart}这才把最重的那句回了出来:“{reply}”", - "aftermath": f"{counterpart}沉了沉气,仍旧把话落得很实:“{reply}”", - "echo": f"{counterpart}临收声前,只留下了一句:“{reply}”", - }.get(beat_key, f"{counterpart}最后只回了一句:“{reply}”") - follow = { - "entry": f"{speaker}指尖缓了一缓,又补了一句:“{followup}”", - "pressure": f"{speaker}像是终于不想再退,顺势把后半句也压了出来:“{followup}”", - "pivot": f"{speaker}没有就此收住,反而把更难听的一句也补到了明处:“{followup}”", - "aftermath": f"{speaker}临到收声前仍没退,只轻轻接了一句:“{followup}”", - "echo": f"{speaker}走出半步又停住,回身补了一句:“{followup}”", - }.get(beat_key, f"{speaker}指尖缓了一缓,又补了一句:“{followup}”") + close_frames = { + "entry": [ + "{counterpart}最后只回了一句:“{line}”", + "{counterpart}没有马上接近,只把话压在原处:“{line}”", + "{counterpart}看了看门外,才把回答落下来:“{line}”", + "{counterpart}把沉默收紧,回得很短:“{line}”", + ], + "pressure": [ + "{counterpart}把话压得很低,只往前送了一句:“{line}”", + "{counterpart}没有让步,反而把后果点明:“{line}”", + "{counterpart}顺着那点停顿反问回来:“{line}”", + "{counterpart}把声音放得更稳:“{line}”", + ], + "pivot": [ + "{counterpart}这才把最重的那句回了出来:“{line}”", + "{counterpart}看清了岔口,回答也不再绕弯:“{line}”", + "{counterpart}没有接旧路,只把选择推回来:“{line}”", + "{counterpart}把视线停住,终于回道:“{line}”", + ], + "aftermath": [ + "{counterpart}沉了沉气,仍旧把话落得很实:“{line}”", + "{counterpart}没有替余波找台阶,只说:“{line}”", + "{counterpart}隔着半息静气,把残局重新推回眼前:“{line}”", + "{counterpart}终于抬眼,回答里没有半点圆场:“{line}”", + ], + "echo": [ + "{counterpart}临收声前,只留下了一句:“{line}”", + "{counterpart}没有让回声空过去,反而接了一句:“{line}”", + "{counterpart}在门边停了停,把余音压成回答:“{line}”", + "{counterpart}最后看了{speaker}一眼:“{line}”", + ], + } + follow_frames = { + "entry": [ + "{speaker}指尖缓了一缓,又补了一句:“{line}”", + "{speaker}没有把这句收回去,反而往前添了一层:“{line}”", + "{speaker}听完那句回答,才把退路也放下:“{line}”", + "{speaker}让门边那点静停了一瞬,继续道:“{line}”", + ], + "pressure": [ + "{speaker}像是终于不想再退,顺势把后半句也压了出来:“{line}”", + "{speaker}没有借沉默避开,只把话换得更实:“{line}”", + "{speaker}把掌心从桌沿松开,接得很快:“{line}”", + "{speaker}迎着那句反问,终于把后半步也走出来:“{line}”", + ], + "pivot": [ + "{speaker}没有就此收住,反而把更难听的一句也补到了明处:“{line}”", + "{speaker}顺着转向往前压了一句:“{line}”", + "{speaker}像是终于认清岔口,补得很轻:“{line}”", + "{speaker}没有回到旧说法,只换了一句更硬的:“{line}”", + ], + "aftermath": [ + "{speaker}临到收声前仍没退,只轻轻接了一句:“{line}”", + "{speaker}没有替自己圆回来,只把代价认得更清:“{line}”", + "{speaker}把余波接在掌心里,慢慢道:“{line}”", + "{speaker}看着{counterpart},终于把残局往自己身上收:“{line}”", + ], + "echo": [ + "{speaker}走出半步又停住,回身补了一句:“{line}”", + "{speaker}没有让回声空着,低声道:“{line}”", + "{speaker}在门外那点风里停了停,又说:“{line}”", + "{speaker}把最后一点余音接回来:“{line}”", + ], + } + close = _frame_variant(close_frames, beat_key, index=variant_index // 3 + chapter_index // 25).format( + speaker=speaker, + counterpart=counterpart, + line=reply, + ) + follow = _frame_variant(follow_frames, beat_key, index=variant_index // 5 + chapter_index // 30).format( + speaker=speaker, + counterpart=counterpart, + line=followup, + ) + if chapter_index >= 20: + return compose_late_longform_compact_exchange( + world, + state_before, + beat, + repeated=repeated, + variant_offset=variant_index, + ) if repeated: - return " ".join([opener, response, close, follow, "两人都知道,话已经绕不过刚才留下的那层意思了。"]) + return " ".join([opener, response, close, follow, _repeated_dialogue_closer(speaker, counterpart, index=variant_index)]) return " ".join([opener, response, close, follow]) diff --git a/src/narrativeos/core/emotion_actions.py b/src/narrativeos/core/emotion_actions.py index b91c6ca..b321d90 100644 --- a/src/narrativeos/core/emotion_actions.py +++ b/src/narrativeos/core/emotion_actions.py @@ -1,28 +1,144 @@ from __future__ import annotations -from ..models import SceneBeat, WorldBible -from .contracts import style_pack_from_world +from ..models import NarrativeState, SceneBeat, WorldBible +from .contracts import PressureResponseStyle, style_pack_from_world def _pick_line(lines: list[str], index: int) -> str: return lines[index % len(lines)] if lines else "" -def compose_emotion_action(world: WorldBible, beat: SceneBeat, *, repeated: bool) -> str: +def _actor_role(state: NarrativeState, actor_id: str) -> str: + character = state.characters.get(actor_id) + return character.role if character else "" + + +def _pressure_style_for_actor( + world: WorldBible, + state: NarrativeState, + actor_id: str, +) -> PressureResponseStyle: + style_pack = style_pack_from_world(world) + if actor_id in style_pack.dialogue.pressure_styles: + return style_pack.dialogue.pressure_styles[actor_id] + role_key = _actor_role(state, actor_id) + if role_key and role_key in style_pack.dialogue.pressure_styles: + return style_pack.dialogue.pressure_styles[role_key] + return PressureResponseStyle( + under_pressure="先压住动作,再把真正难退的那一步慢慢逼近。", + when_cornered="被逼到边上时,反而不再替自己留太多退路。", + when_softening="语气先松下来,但心里的边界没有一起撤掉。", + when_deflecting="想把真心挪开半寸,却又没法真的装作若无其事。", + ) + + +def _duty_focus(duty_type: str) -> str: + return { + "advance_plot": "局势和后果往前推近", + "advance_relationship": "两人之间那点靠近与试探", + "resolve_promise": "迟早要认下的旧账和真话", + "expand_world": "眼前这一局背后的旧规矩与更大代价", + "pace_breath": "表面缓下来、心里却没真正散掉的余波", + "deliver_climax": "已经没法回头的那一步选择", + }.get(duty_type, "这一步还没说透的心事") + + +def _pressure_phrase(style: PressureResponseStyle, job: str) -> str: + mapping = { + "entry": style.under_pressure, + "pressure": style.when_cornered or style.under_pressure, + "pivot": style.when_cornered or style.when_deflecting, + "aftermath": style.when_softening or style.under_pressure, + "echo": style.when_deflecting or style.when_softening, + } + return str(mapping.get(job) or style.under_pressure or "动作先稳住了,可真正难退的那一步并没有散。") + + +def _fallback_variants( + *, + duty_type: str, + scene_function: str, + job: str, + pressure_phrase: str, +) -> list[str]: + scene_label = scene_function.replace("_", " ") + focus = _duty_focus(duty_type) + return { + "entry": [ + f"{pressure_phrase},连{focus}都像先被拢到了这一步{scene_label}跟前。", + f"最先绷紧的不是声量,而是{focus};{pressure_phrase}", + f"场面还没真动起来,{focus}却已经被这一步{scene_label}轻轻挑开了口子。", + ], + "pressure": [ + f"{pressure_phrase},把{focus}一路压到了最难回避的位置。", + f"真正逼人的不是哪句重话,而是{focus}在这一步{scene_label}里已经没法再往后撤。", + f"{focus}被一点点推近,连最轻的停顿都像在替这一步{scene_label}加重分量。", + ], + "pivot": [ + f"{pressure_phrase},场面便从还能周旋,变成了{focus}不得不选边。", + f"最轻的一点改口,都把{focus}从暗处推到了明面上。", + f"这一瞬真正拧紧的,是{focus}终于被这一步{scene_label}逼得不能再装稳。", + ], + "aftermath": [ + f"{pressure_phrase},留下来的却是{focus}比刚才更沉了一层。", + f"人虽然先收住了,{focus}却还停在原地,像迟早要回来索账。", + f"话音落下后最压人的,反而是{focus}已经没法轻轻放回去了。", + ], + "echo": [ + f"{pressure_phrase},等人声退下去时,真正追上来的还是{focus}。", + f"越到后面,越能听见{focus}沿着这一步{scene_label}慢慢回身索账。", + f"场面像是先静了,可{focus}还在更慢地逼近,没打算就这样散掉。", + ], + }.get(job, [f"{pressure_phrase},{focus}已经被这一步{scene_label}推到了更近的地方。"]) + + +def compose_emotion_action(world: WorldBible, state_before: NarrativeState, beat: SceneBeat, *, repeated: bool) -> str: style_pack = style_pack_from_world(world) scene_function = beat.event.scene_function job = beat.dramatic_job + duty_type = str((state_before.current_chapter_task or {}).get("duty_type") or "") beat_index = getattr(beat, "beat_index", 0) + event_seed = sum(ord(char) for char in str(getattr(beat.event, "event_id", "") or "")) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + variant_index = beat_index + event_seed + chapter_index * 5 action_pool = style_pack.emotion_actions.action_map.get(scene_function, {}) if repeated and action_pool.get("repeat"): - return _pick_line(action_pool["repeat"], beat_index) + return _pick_line(action_pool["repeat"], variant_index) if action_pool.get(job): - return _pick_line(action_pool[job], beat_index) + return _pick_line(action_pool[job], variant_index) + actor_id = beat.event.actors[0] if beat.event.actors else "" + pressure_style = _pressure_style_for_actor(world, state_before, actor_id) if actor_id else PressureResponseStyle() + duty_defaults = _fallback_variants( + duty_type=duty_type, + scene_function=scene_function, + job=job, + pressure_phrase=_pressure_phrase(pressure_style, job), + ) defaults = { - "entry": "桌上的器物轻轻一碰,谁都知道这一步已经走出去,很难再收回来。", - "pressure": "连抬眼、换气和指尖的细小停顿都带上了掂量,像谁先多动一下,谁就会先露底。", - "pivot": "那一点极轻的停顿和改口,让场面从还能周旋,变成了不得不选边。", - "aftermath": "说出口的那几句已经停了,可散开时每个人都比来时更沉。", - "echo": "越到最后,越能听见那些没说尽的话在场里慢慢回身索账。", + "entry": [ + "桌上的器物轻轻一碰,谁都知道这一步已经走出去,很难再收回来。", + "衣角和桌沿只轻轻擦了一下,场面里的分寸却已经开始变窄。", + "谁也没有大动作,可气氛先一步绷紧,像一句话已经碰到嘴边。", + ], + "pressure": [ + "连抬眼、换气和指尖的细小停顿都带上了掂量,像谁先多动一下,谁就会先露底。", + "呼吸和目光都慢了半拍,仿佛谁先把话挑明,谁就得先承担代价。", + "细到指节收紧、肩背发沉的变化都压在场面上,让人再难装作无事。", + ], + "pivot": [ + "那一点极轻的停顿和改口,让场面从还能周旋,变成了不得不选边。", + "不过一瞬的沉默,局势就从还可拖延,变成了谁都得给出站位。", + "那一下看似轻微的收声,反而把最重的选择推到了明处。", + ], + "aftermath": [ + "说出口的那几句已经停了,可散开时每个人都比来时更沉。", + "话音落下以后,谁也没立刻动,反倒让余下那层难堪更清楚了。", + "真正压人的不是那几句话本身,而是它们停下以后还留在场里的回声。", + ], + "echo": [ + "越到最后,越能听见那些没说尽的话在场里慢慢回身索账。", + "场面像是先静了,可没落地的那点情绪反而在更慢地逼近。", + "人声退下去之后,留下来的不是轻松,而是更难绕开的回响。", + ], } - return defaults.get(job, "动作并不大,可局势已经变了味道。") + return _pick_line(duty_defaults or defaults.get(job, ["动作并不大,可局势已经变了味道。"]), variant_index) diff --git a/src/narrativeos/core/linter.py b/src/narrativeos/core/linter.py index 9f7493e..ef5460f 100644 --- a/src/narrativeos/core/linter.py +++ b/src/narrativeos/core/linter.py @@ -2,7 +2,7 @@ from typing import Dict -from ..prose_linter import lint_prose +from ..prose_linter import lint_prose, story_text_unit_count def lint_chapter_draft(text: str) -> Dict[str, object]: diff --git a/src/narrativeos/core/quality_pass.py b/src/narrativeos/core/quality_pass.py index bd38208..4499acc 100644 --- a/src/narrativeos/core/quality_pass.py +++ b/src/narrativeos/core/quality_pass.py @@ -3,100 +3,2623 @@ import re from typing import List, Sequence -from ..models import ChapterDraft, NarrativeState, SceneBeat, ScenePlan, WorldBible -from .dialogue import compose_dialogue +from ..long_route_quality import clean_broken_reader_slots +from ..models import ChapterDraft, NarrativeState, SceneBeat, ScenePlan, SceneRenderSpec, WorldBible +from ..repetition_detector import repetition_signal_bundle +from .dialogue import compose_dialogue, compose_late_longform_compact_exchange from .emotion_actions import compose_emotion_action -from .linter import lint_chapter_draft +from .linter import lint_chapter_draft, story_text_unit_count from .scene_realizer import realize_hook from .sensory_grounding import scene_atmosphere, scene_detail -ACTION_MARKERS = ["抬", "落", "偏", "按", "推", "站", "看", "握", "停", "拢"] -DETAIL_MARKERS = ["灯", "袖", "茶", "风", "窗", "案", "影", "香", "光", "声", "纸"] +ACTION_MARKERS = ["抬", "落", "偏", "按", "推", "站", "看", "握", "停", "拢", "压", "掠", "碰", "擦", "收", "绷", "卷", "撞", "回", "拨", "绕", "贴", "拖"] +DETAIL_MARKERS = [ + "灯", "袖", "茶", "风", "窗", "案", "影", "香", "光", "声", "纸", + "栏", "栏杆", "杯", "杯沿", "门框", "木板", "纸页", "桌沿", "桌角", "器物", "石径", "叶影", + "扫描台", "蓝线", "红灯", "防潮盒", "钝印", "胶痕", "签章", "声纹", "画稿", "盐壳", "录音笔", "话筒", + "石砖", "空杯", "窗纸", "木栏", "地板", "檐角", "冷光", "回声", "香灰", "笔架", "卷面", "号板", + "墨迹", "鞋底", "手背", "发梢", "灰尘", "水痕", "潮气", "湿气", "衣摆", "袖口", + "指节", "呼吸", "肩背", "掌心", "眼睫", "廊柱", "石阶", "花枝", "帘钩", "玉佩", "朱批", "折角", + "灯座", "玉阶", "香炉", "钟声", "檀香", "冷雾", "山门", "剑穗", "符纸", "云气", "霜意", + "湖面", "石栏", "水声", "水雾", "月色", "水线", "浪声", "水滴声", "盐味", "潮痕", + "雨棚", "旧门牌", "雨伞骨", "监控探头", "电流声", "鞋底水声", "翻卷声", "霓虹", "湿雾", "油烟", +] DETAIL_DENSITY_FLOOR = 1.0 / 180.0 +CONTRACT_DETAIL_DENSITY_FLOOR = 0.04 +LONGFORM_DETAIL_DENSITY_POLISH_TARGET = 0.085 +LONGFORM_DETAIL_DENSITY_POLISH_FLOOR = 0.075 +LONGFORM_STOP_READY_DIALOGUE_TARGET = 0.56 +LONGFORM_STOP_READY_EXPOSITION_TARGET = 0.50 +SENTENCE_BOUNDARY_PATTERN = re.compile(r"(?<=[。!?!?])") +CONTINUATION_HOOK_TOKENS = ["下一次", "还会", "追上来", "未说尽", "后面还有", "下一章", "还没有散"] +LONGFORM_SUSPICIOUS_REFRAIN_REPLACEMENTS = { + "真话窗口": ("开口的缝隙", "能说实话的一刻", "那道短暂的缝"), + "把每一步都接住": ("把眼前这一步稳住", "先接住当前的后果", "让下一步落在实处"), + "别再漏掉": ("别让关键处滑开", "不能再放过这处", "把这处补实"), + "真正要转向的那句终于逼到眼前": ("那句该说的话贴近眼前", "局面逼出必须回应的一句", "被拖住的回答到了近前"), + "被压回去的": ("没说出口的", "被藏住的", "被按下去的"), + "顺着此刻的局势先退半步,再找一个更稳的开口。": ("先稳住眼前的变化,再换一个更清楚的问法。", "先接住当前的后果,再追下一处空白。", "不急着后退,先把这处裂口看清楚。"), +} +SCENE_FUNCTION_LABELS = { + "false_peace": "表面平静", + "temptation": "试探", + "truth_trial": "真相逼近", + "mask_crack": "裂口", + "confession_window": "真话窗口", + "debt_exchange": "旧账回潮", + "karma_ripening": "因果回响", + "humiliation": "难堪代价", + "vow_payment": "誓言偿付", + "misrecognition": "误解升级", + "mercy_vs_control": "庇护与控制", +} def _normalize(text: str) -> str: return re.sub(r"\s+", "", text.strip()) +def _has_continuation_hook(text: str) -> bool: + candidate = str(text or "") + if any(token in candidate for token in CONTINUATION_HOOK_TOKENS): + return True + return bool(re.search(r"还没(?:有)?(?:说完|做完|追上|散尽)", candidate)) + + +def _beat_seed(beat: SceneBeat, *, chapter_index: int = 0, extra: int = 0) -> int: + event_id = str(getattr(beat.event, "event_id", "") or "") + title = str(getattr(beat.event, "title", "") or "") + scene_function = str(getattr(beat.event, "scene_function", "") or "") + beat_index = int(getattr(beat, "beat_index", 0) or 0) + return sum(ord(char) for char in f"{event_id}:{title}:{scene_function}") + beat_index * 19 + int(chapter_index) * 37 + int(extra) + + +def _char_ngrams(text: str, *, size: int = 8) -> set[str]: + normalized = _normalize(text) + if len(normalized) < size: + return {normalized} if normalized else set() + return {normalized[index : index + size] for index in range(0, len(normalized) - size + 1)} + + +def _sentence_similarity(left: str, right: str) -> float: + left_grams = _char_ngrams(left) + right_grams = _char_ngrams(right) + if not left_grams or not right_grams: + return 0.0 + return len(left_grams & right_grams) / float(max(1, len(left_grams | right_grams))) + + +def _scene_focus_label(beat: SceneBeat) -> str: + title = _reader_visible_anchor(str(getattr(beat.event, "title", "") or "").strip()) + beat_label = _reader_visible_anchor(str(getattr(beat, "beat_label", "") or "").strip()) + if "·" in title: + title = title.split("·", 1)[1].strip() + if "·" in beat_label: + beat_label = beat_label.split("·", 1)[-1].strip() + if ":" in beat_label: + beat_label = beat_label.split(":", 1)[1].strip() + beat_label = beat_label.lstrip("·- ") + title = title.lstrip("·- ") + candidate = beat_label or title or SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + if len(candidate) > 12: + return SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + return candidate + + +def _paragraph_similarity(left: str, right: str) -> float: + return _sentence_similarity(left, right) + + +def _needs_paragraph_replacement( + paragraph: str, + previous_paragraphs: Sequence[str], + *, + beat: SceneBeat, + paragraph_index: int, + total_paragraph_count: int, +) -> bool: + normalized = _normalize(paragraph) + if total_paragraph_count >= 6 and paragraph_index < total_paragraph_count - 1 and len(normalized) < 32: + return True + if len(normalized) < 60 and len(previous_paragraphs) < 2: + return False + focus = _scene_focus_label(beat) + if focus and len(focus) >= 4 and paragraph.count(focus) >= 3: + return True + if paragraph.count("这一步") >= 3: + return True + max_similarity = max((_paragraph_similarity(paragraph, prior) for prior in previous_paragraphs), default=0.0) + if len(normalized) >= 180 and max_similarity >= 0.34: + return True + if len(normalized) >= 120 and max_similarity >= 0.42: + return True + return False + + +def _paragraph_replacement( + *, + world: WorldBible, + state_before: NarrativeState, + beat: SceneBeat, + paragraph_index: int, + chapter_index: int, + previous_paragraphs: Sequence[str], + source_paragraph: str | None = None, +) -> str: + candidates = [ + " ".join( + [ + _dialogue_pressure_paragraph(world, state_before, beat), + _action_pressure_paragraph(world, state_before, beat), + ] + ).strip(), + " ".join( + [ + _action_pressure_paragraph(world, state_before, beat), + _detail_reinforcement_paragraph( + world, + beat, + chapter_index=chapter_index, + variant_seed=650 + paragraph_index, + ), + ] + ).strip(), + " ".join( + [ + _dialogue_pressure_paragraph(world, state_before, beat), + _detail_reinforcement_paragraph( + world, + beat, + chapter_index=chapter_index, + variant_seed=700 + paragraph_index, + ), + ] + ).strip(), + _beat_variation_paragraph(world, state_before, beat), + _action_pressure_paragraph(world, state_before, beat), + _dialogue_pressure_paragraph(world, state_before, beat), + ] + best = candidates[0] + best_similarity = 1.0 + for candidate in candidates: + similarity = max((_paragraph_similarity(candidate, prior) for prior in previous_paragraphs), default=0.0) + if similarity < best_similarity: + best = candidate + best_similarity = similarity + source_anchor = _source_anchor_for_beat(source_paragraph or "", beat) + if source_anchor and source_anchor not in best: + best = f"{source_anchor}没有被场面带过。 {best}" + return best + + +def _replace_redundant_paragraphs_after_expansion( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + remediation_actions: List[str], +) -> List[str]: + if not scene_beats: + return list(paragraphs) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + rewritten: List[str] = [] + for index, paragraph in enumerate(paragraphs): + beat = _beat_for_paragraph(paragraph, scene_beats, fallback_index=index) + if _needs_paragraph_replacement( + paragraph, + rewritten, + beat=beat, + paragraph_index=index, + total_paragraph_count=len(paragraphs), + ): + paragraph = _paragraph_replacement( + world=world, + state_before=state_before, + beat=beat, + paragraph_index=index, + chapter_index=chapter_index, + previous_paragraphs=rewritten, + source_paragraph=paragraph, + ) + remediation_actions.append(f"q03_post_length_paragraph_replace:{index}") + rewritten.append(paragraph) + attempts = 0 + while attempts < 4: + bundle = repetition_signal_bundle(rewritten) + if float(bundle.get("overall_repetition_pressure", 0.0) or 0.0) < 0.38: + break + target_index = None + for index, paragraph in enumerate(rewritten[:-1]): + if len(_normalize(paragraph)) < 32: + target_index = index + break + if target_index is None: + top_pairs = list(bundle.get("top_repeated_paragraph_pairs") or []) + if top_pairs and float(top_pairs[0].get("similarity", 0.0) or 0.0) >= 0.22: + target_index = int(top_pairs[0].get("right_paragraph_index", 0) or 0) + if target_index is None or target_index >= len(rewritten): + break + source_paragraph = rewritten[target_index] + beat = _beat_for_paragraph(source_paragraph, scene_beats, fallback_index=target_index) + rewritten[target_index] = _paragraph_replacement( + world=world, + state_before=state_before, + beat=beat, + paragraph_index=target_index + attempts + 100, + chapter_index=chapter_index, + previous_paragraphs=rewritten[:target_index], + source_paragraph=source_paragraph, + ) + remediation_actions.append(f"q03_bundle_target_replace:{target_index}") + attempts += 1 + return rewritten + + def _actor_name(state_before: NarrativeState, actor_id: str) -> str: character = state_before.characters.get(actor_id) return character.name if character else actor_id.replace("_", " ") -def _rebuild_draft(paragraphs: Sequence[str], metadata: dict[str, object]) -> ChapterDraft: - cleaned = [paragraph.strip() for paragraph in paragraphs if paragraph and paragraph.strip()] - body = "\n\n".join(cleaned) - return ChapterDraft( - body=body, - paragraphs=cleaned, - dialogue_count=body.count("“"), - action_count=sum(body.count(marker) for marker in ACTION_MARKERS), - detail_count=sum(body.count(marker) for marker in DETAIL_MARKERS), - metadata=metadata, - ) +def _rebuild_draft(paragraphs: Sequence[str], metadata: dict[str, object]) -> ChapterDraft: + cleaned = [paragraph.strip() for paragraph in paragraphs if paragraph and paragraph.strip()] + body = "\n\n".join(cleaned) + return ChapterDraft( + body=body, + paragraphs=cleaned, + dialogue_count=body.count("“"), + action_count=sum(body.count(marker) for marker in ACTION_MARKERS), + detail_count=sum(body.count(marker) for marker in DETAIL_MARKERS), + metadata=metadata, + ) + + +def _detail_density_snapshot(paragraphs: Sequence[str]) -> dict[str, float | int]: + body = "\n\n".join(str(paragraph or "") for paragraph in paragraphs) + detail_count = sum(body.count(marker) for marker in DETAIL_MARKERS) + unit_count = story_text_unit_count(body) + return { + "detail_count": detail_count, + "text_unit_count": unit_count, + "concrete_detail_density": detail_count / float(max(1, unit_count)), + } + + +def _beat_variation_paragraph(world: WorldBible, state_before: NarrativeState, beat: SceneBeat) -> str: + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + if len(beat.event.actors) < 2: + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + return " ".join( + [ + scene_atmosphere(world, beat, chapter_index=chapter_index), + f"{actor_name}把目光压回眼前那一点光影里,像是先替自己把最难认的那句话按住。", + scene_detail(world, beat, repeated=True, chapter_index=chapter_index), + ] + ) + return " ".join( + [ + scene_atmosphere(world, beat, chapter_index=chapter_index), + compose_emotion_action(world, state_before, beat, repeated=True), + scene_detail(world, beat, repeated=True, chapter_index=chapter_index), + compose_dialogue(world, state_before, beat, repeated=True), + ] + ) + + +def _dialogue_pressure_paragraph(world: WorldBible, state_before: NarrativeState, beat: SceneBeat) -> str: + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + seed = _beat_seed(beat, chapter_index=chapter_index) + if len(beat.event.actors) < 2: + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + lines = [ + "我先把这句话逼到明处,不再让它只在心里兜圈。", + "这一回不能照旧绕开,我得换一种说法,也换一种做法。", + "如果这一步已经露出来,我就不能再让它留给下一次。", + "我先接住眼前这一下,后面的代价再一件件认。", + ] + return " ".join( + [ + scene_atmosphere(world, beat, chapter_index=chapter_index), + f"{actor_name}低声道:“{lines[seed % len(lines)]}”", + scene_detail(world, beat, repeated=True, chapter_index=chapter_index), + ] + ) + return " ".join( + [ + scene_atmosphere(world, beat, chapter_index=chapter_index), + compose_dialogue(world, state_before, beat, repeated=True), + compose_emotion_action(world, state_before, beat, repeated=False), + scene_detail(world, beat, repeated=True, chapter_index=chapter_index), + ] + ) + + +def _action_pressure_paragraph(world: WorldBible, state_before: NarrativeState, beat: SceneBeat) -> str: + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + seed = _beat_seed(beat, chapter_index=chapter_index) + location = beat.event.location or "眼前这一处" + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + counterpart = _actor_name(state_before, beat.event.actors[1]) if len(beat.event.actors) > 1 else "对面那人" + scene_function = SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + focus = _scene_focus_label(beat) + detail = scene_detail(world, beat, repeated=False, chapter_index=chapter_index) + actor_moves = [ + f"{actor_name}先抬手按住{location}边的门框,又偏头看了{counterpart}一眼,指节在案角那页纸上轻轻一擦,像把原本想退回去的话重新压到了明处。", + f"{actor_name}没有先把话送出来,只把手背贴在{location}边的桌沿上,等{focus}真正逼到眼前,才把那口气慢慢压稳。", + f"{actor_name}先把脚步收在{location}边那一线冷光里,视线却一直没从{counterpart}身上移开,像这一步只要退半寸就会把{focus}整个让出去。", + f"{actor_name}抬手碰了一下{location}边的器物,任那点细响顺着桌沿和门影散开,像在替自己把{focus}硬生生摁到明处。", + ] + counterpart_moves = [ + f"{counterpart}没有立刻退,只把衣袖往回一收,脚步在阶前停了一瞬,目光却顺着灯影和窗边那道风重新掠回来,硬是把这一步{scene_function}往前推近了一层。", + f"{counterpart}没有替场面找台阶,只把呼吸压得更稳,任{location}里的回声和冷光一层层逼近,像逼着{actor_name}把{focus}认得更彻底。", + f"{counterpart}先抬眼盯住{actor_name},连指尖碰到案角时那点轻响都没躲开,像要让这一步{scene_function}彻底失去还能装稳的样子。", + f"{counterpart}并不急着开口,只把身形停在{location}边最亮的那一线,像等{actor_name}自己把{focus}送到再也收不回去的地方。", + ] + closers = [ + f"{actor_name}低声道:“这句我不再往回收了。”", + f"{actor_name}终于把声音压实:“这一步我认,不再拿解释替自己留路。”", + f"{actor_name}顺着那点停顿把话送了出来:“都已经走到这里,我没法再把{focus}装成没发生。”", + f"{actor_name}盯着{counterpart}时只落下一句:“这一次我不让它停在半句。”", + ] + return " ".join( + [ + scene_atmosphere(world, beat, chapter_index=chapter_index), + detail, + actor_moves[seed % len(actor_moves)], + counterpart_moves[(seed // 3) % len(counterpart_moves)], + closers[(seed // 7) % len(closers)], + ] + ) + + +def _compact_action_dialogue_sentence( + world: WorldBible, + state_before: NarrativeState, + beat: SceneBeat, + *, + variant_seed: int = 0, +) -> str: + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + seed = _beat_seed(beat, chapter_index=chapter_index, extra=variant_seed) + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + counterpart = _actor_name(state_before, beat.event.actors[1]) if len(beat.event.actors) > 1 else "对面那人" + location = beat.event.location or "眼前这一处" + focus = _scene_focus_label(beat) or SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + variants = [ + f"{actor_name}抬手按住{location}边的案角,指节收紧又松开,目光绕过灯影停在{counterpart}身上,低声道:“这一回我认。”", + f"{counterpart}没有退,反而往前半步看住{actor_name},袖口一拢又压住桌沿,声音很轻:“别再往回收。”", + f"{actor_name}把脚步停稳,手背贴着{location}边那道冷光慢慢收紧,终于抬眼:“这句不能再留一半。”", + f"{counterpart}偏头看了一眼门影,又把手里的纸页推回案上,低声道:“你若认,就现在认。”", + f"{actor_name}把原先要重复的半句话咽下去,指尖在纸页边缘停住:“我换成行动给你看。”", + f"{counterpart}垂眼看着案角那道划痕,没让沉默散开:“说到{focus},就别只停在嘴上。”", + f"{location}边的门影轻轻一晃,{actor_name}往前站稳半步:“旧账也好,后果也好,我现在接。”", + f"{counterpart}把茶盏推开一点,杯沿碰出轻响:“那就从眼前这件事开始。”", + f"{actor_name}没有再解释,只把手里的纸页翻到压痕最深处:“这里,我先补上。”", + f"{counterpart}看了一眼窗下冷光,声音压得更低:“别让这层代价空过去。”", + ] + return variants[seed % len(variants)] + + +def _detail_reinforcement_paragraph( + world: WorldBible, + beat: SceneBeat, + *, + chapter_index: int = 0, + variant_seed: int = 0, +) -> str: + location = beat.event.location or "眼前这一处" + scene_function = SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + detail = scene_detail(world, beat, repeated=False, chapter_index=chapter_index) + variants = [ + f"{location}里的风、门、窗、灯影和衣角摩擦出的细响并没有停,连案边那页纸、阶前那点回声和若有若无的香气都把这一步{scene_function}压得更近。", + f"{location}边的杯沿、门框、纸页和鞋底擦出的轻响层层往回推,像把这一步{scene_function}里每一点迟疑都钉在了桌面、地砖和窗影上。", + f"{location}里的尘、潮气、木纹、灯火和器物反光一起往人身上贴,连袖角扫过案边时带起的那点声响都让这一步{scene_function}更难装作没发生。", + f"到了{location}里面,窗纸的冷光、桌沿的磨痕、门边的风和衣摆擦过地面的细响全挤到一起,让这一步{scene_function}不靠解释也能压出重量。", + f"{location}边最先显出来的是器物、回声和人身上那点收不回去的动作,连案角、门框和空气里的细碎冷意都在替这一步{scene_function}留下更具体的痕。", + f"{location}里的灯、纸、门影和脚步回响没有替谁遮掩,反而把这一步{scene_function}里每一点犹疑、停顿和后果都逼得更能摸到。", + ] + seed = _beat_seed(beat, chapter_index=chapter_index, extra=variant_seed) + return " ".join( + [ + scene_atmosphere(world, beat, chapter_index=chapter_index), + detail, + variants[seed % len(variants)], + ] + ) + + +def _coverage_gap_bridge_paragraph( + world: WorldBible, + state_before: NarrativeState, + beat: SceneBeat, + *, + variant_seed: int = 0, + chapter_index: int = 0, +) -> str: + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + counterpart = _actor_name(state_before, beat.event.actors[1]) if len(beat.event.actors) > 1 else "对面那人" + location = beat.event.location or "眼前这一处" + title = _compact_title_anchor(_reader_visible_anchor(str(getattr(beat.event, "title", "") or "")), location=location) + beat_label = _reader_visible_anchor(str(getattr(beat, "beat_label", "") or "")) + summary = _reader_visible_anchor(str(getattr(beat.event, "summary", "") or "")) + summary = _compact_summary_anchor(summary, title=title, world_title=world.title) + tags = [ + _reader_visible_anchor(str(item)) + for item in list(getattr(beat.event, "tags", []) or []) + if _reader_visible_anchor(str(item)) + ] + tag_anchor = _compact_anchor_line([tag[:42] for tag in tags[:2]]) + anchor_line = _compact_anchor_line( + [ + _compact_label_anchor(beat_label, title=title), + title, + summary, + _reader_visible_anchor(str(location)), + "、".join(tags[:3]), + ] + ) + dramatic_job = str(getattr(beat, "dramatic_job", "") or "pressure") + job_label = { + "entry": "先把事情推到台面上的", + "pressure": "往前逼近的", + "pivot": "让局面转过去的", + "aftermath": "在余波里继续追上来的", + "echo": "隔一层声响又折回来的", + }.get(dramatic_job, "最难躲开的") + detail = scene_detail(world, beat, repeated=False, chapter_index=chapter_index) + seed = _beat_seed(beat, chapter_index=chapter_index, extra=variant_seed) + anchor_subject = title or beat_label or summary or str(location) + variants = [ + f"{anchor_line or anchor_subject}没有被场面轻轻带过。", + f"{anchor_line or anchor_subject}压回{location}时,没有再被一句解释遮过去。", + f"{anchor_line or anchor_subject}真正{job_label}不是旁白,而是当场压到两人面前。", + f"{location}里的脚步和回声把{anchor_line or anchor_subject}压实,谁都没法再绕开。", + ] + bridge = variants[seed % len(variants)].strip() + if anchor_line and anchor_line not in bridge: + bridge = " ".join([anchor_line, bridge]).strip() + anchor_echo = _compact_anchor_line([title, _compact_label_anchor(beat_label, title=title), summary]) + if anchor_echo and anchor_echo not in bridge: + bridge = f"{bridge} {anchor_echo}没有只停在背景里。" + if tag_anchor and tag_anchor not in bridge: + bridge = f"{bridge} {tag_anchor}也被拉回场中。" + pressure_lines = [ + f"{actor_name}抬手按住案角:“这句我认。” {counterpart}没有替他收场。", + f"{counterpart}把沉默压住,逼得{actor_name}把退路看清。{actor_name}只回:“这次不能绕。”", + f"{actor_name}的指节在{location}边停了停。{counterpart}看着他,没有给这层后果留下空白。", + f"{location}里的门影一晃,{actor_name}把声音压稳:“我接住。” {counterpart}没有退。", + ] + detail_tail = f"案角、门框、纸页、杯沿和地板冷光都在{location}边停住。" + return " ".join([bridge, detail, detail_tail, pressure_lines[(seed // 7) % len(pressure_lines)]]).strip() + + +def _multi_beat_coverage_paragraph( + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + *, + variant_seed: int = 0, + chapter_index: int = 0, +) -> str: + clauses: List[str] = [] + for offset, beat in enumerate(scene_beats[:6]): + location = beat.event.location or "眼前这一处" + title = _compact_title_anchor(_reader_visible_anchor(str(getattr(beat.event, "title", "") or "")), location=location) + beat_label = _compact_label_anchor(_reader_visible_anchor(str(getattr(beat, "beat_label", "") or "")), title=title) + summary = _compact_summary_anchor( + _reader_visible_anchor(str(getattr(beat.event, "summary", "") or "")), + title=title, + world_title=world.title, + ) + tags = [ + _reader_visible_anchor(str(item)) + for item in list(getattr(beat.event, "tags", []) or []) + if _reader_visible_anchor(str(item)) + ] + anchor = _compact_anchor_line([beat_label, title, summary, _reader_visible_anchor(str(location)), *[tag[:42] for tag in tags[:1]]]) + if not anchor: + anchor = SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + seed = _beat_seed(beat, chapter_index=chapter_index, extra=variant_seed + offset) + action_tail = [ + f"{actor_name}把手按到桌沿,没让它从场面里滑过去", + f"{actor_name}抬眼看住对面,像把这一层后果重新钉回眼前", + f"{actor_name}停住脚步,让那点声响顺着门影落下来", + f"{actor_name}压低呼吸,终于把这一步接到自己的动作上", + ][seed % 4] + clauses.append(f"{anchor}被{location}里的光和声重新逼近时,{action_tail}。") + if not clauses: + return "" + last_beat = scene_beats[(variant_seed + chapter_index) % len(scene_beats)] + actor_name = _actor_name(state_before, last_beat.event.actors[0]) if last_beat.event.actors else "那人" + counterpart = _actor_name(state_before, last_beat.event.actors[1]) if len(last_beat.event.actors) > 1 else "对面那人" + closers = [ + f"{counterpart}没有替{actor_name}收场,只低声道:“先把眼前这一步说实。”", + f"{counterpart}把目光留在{actor_name}手边:“这处空白,现在补上。”", + f"{actor_name}没有再后退,只把声音压低:“我按眼前的后果来。”", + f"{counterpart}往前半步:“别让这一处从场面里滑开。”", + ] + return " ".join( + [ + scene_atmosphere(world, last_beat, chapter_index=chapter_index), + " ".join(clauses), + closers[(variant_seed + chapter_index + len(clauses)) % len(closers)], + ] + ).strip() + + +def _coverage_gap_surface_paragraphs( + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + *, + variant_seed: int = 0, + chapter_index: int = 0, + max_count: int = 4, +) -> List[str]: + paragraphs: List[str] = [] + for offset, beat in enumerate(list(scene_beats)[: max(1, int(max_count or 1))]): + paragraphs.append( + _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=variant_seed + offset * 37, + chapter_index=chapter_index, + ) + ) + return paragraphs + + +def _coverage_anchor_echo_paragraph( + world: WorldBible, + state_before: NarrativeState, + beat: SceneBeat, + *, + variant_seed: int = 0, + chapter_index: int = 0, +) -> str: + location = _reader_visible_anchor(str(getattr(beat.event, "location", "") or "")) or "眼前这一处" + title = _reader_visible_anchor(str(getattr(beat.event, "title", "") or "")) + beat_label = _reader_visible_anchor(str(getattr(beat, "beat_label", "") or "")) + summary = _compact_summary_anchor( + _reader_visible_anchor(str(getattr(beat.event, "summary", "") or "")), + title=title, + world_title=world.title, + ) + scene_label = SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + tags = [ + _reader_visible_anchor(str(item)) + for item in list(getattr(beat.event, "tags", []) or []) + if _reader_visible_anchor(str(item)) + ] + anchor_line = _compact_anchor_line( + [ + _compact_label_anchor(beat_label, title=title), + title, + summary, + location, + scene_label, + " ".join(tags[:3]), + ] + ) + if not anchor_line: + anchor_line = title or beat_label or scene_label or location + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + counterpart = _actor_name(state_before, beat.event.actors[1]) if len(beat.event.actors) > 1 else "对面那人" + seed = _beat_seed(beat, chapter_index=chapter_index, extra=variant_seed) + pressure_lines = [ + f"{actor_name}抬手按住{location}边那道冷光:“这件事不能漏过去。” {counterpart}没有替他收场,只把那一层后果重新推回眼前。", + f"{counterpart}把视线压在{actor_name}身上:“你先把它接住。” {actor_name}的指节贴着桌沿停住,像终于承认这一步不能从场面里滑走。", + f"{actor_name}没有再让话绕开,只把脚步停在{location}最亮的地方:“我现在认。” {counterpart}听见这句后反而往前半步。", + ] + return " ".join( + [ + f"{anchor_line}没有被余波遮过去。", + scene_atmosphere(world, beat, chapter_index=chapter_index), + scene_detail(world, beat, repeated=False, chapter_index=chapter_index), + pressure_lines[seed % len(pressure_lines)], + ] + ).strip() + + +def _coverage_gap_target_beats( + scene_beats: Sequence[SceneBeat], + repetition_bundle: dict[str, object], +) -> List[SceneBeat]: + if not scene_beats: + return [] + targeted_event_ids: List[str] = [] + for item in list(repetition_bundle.get("coverage_gap_examples") or []): + event_id = str((item or {}).get("event_id") or "").strip() + if event_id: + targeted_event_ids.append(event_id) + + selected: List[SceneBeat] = [] + seen_ids: set[str] = set() + for event_id in targeted_event_ids: + for beat in scene_beats: + beat_event_id = str(getattr(beat.event, "event_id", "") or "").strip() + if beat_event_id == event_id and beat_event_id not in seen_ids: + selected.append(beat) + seen_ids.add(beat_event_id) + break + + if not selected: + fallback_candidates = [ + scene_beats[0], + scene_beats[min(1, len(scene_beats) - 1)], + scene_beats[-1], + ] + for beat in fallback_candidates: + beat_event_id = str(getattr(beat.event, "event_id", "") or "").strip() + dedupe_key = beat_event_id or f"{beat.beat_index}:{beat.event.title}" + if dedupe_key in seen_ids: + continue + seen_ids.add(dedupe_key) + selected.append(beat) + + return selected[:3] + + +def _expanded_coverage_target_beats( + scene_beats: Sequence[SceneBeat], + repetition_bundle: dict[str, object], + *, + limit: int = 6, +) -> List[SceneBeat]: + selected = list(_coverage_gap_target_beats(scene_beats, repetition_bundle)) + seen_ids = { + str(getattr(beat.event, "event_id", "") or "") or f"{beat.beat_index}:{beat.event.title}" + for beat in selected + } + for beat in scene_beats: + dedupe_key = str(getattr(beat.event, "event_id", "") or "") or f"{beat.beat_index}:{beat.event.title}" + if dedupe_key in seen_ids: + continue + selected.append(beat) + seen_ids.add(dedupe_key) + if len(selected) >= limit: + break + return selected[:limit] + + +def _coverage_context_for_beats(scene_beats: Sequence[SceneBeat]) -> dict[str, object]: + return { + "selected_event_ids": [ + str(getattr(beat.event, "event_id", "") or "") + for beat in scene_beats + if str(getattr(beat.event, "event_id", "") or "").strip() + ], + "scene_beats": [beat.to_dict() if hasattr(beat, "to_dict") else beat for beat in scene_beats], + } + + +def _coverage_repetition_bundle( + paragraphs: Sequence[str], + scene_beats: Sequence[SceneBeat], +) -> dict[str, object]: + return dict( + repetition_signal_bundle( + paragraphs, + coverage_context=_coverage_context_for_beats(scene_beats), + ) + ) + + +def _paragraph_anchor_score(paragraph: str, scene_beats: Sequence[SceneBeat]) -> int: + score = 0 + for beat in scene_beats: + title = _reader_visible_anchor(str(getattr(beat.event, "title", "") or "")) + summary = _reader_visible_anchor(re.sub( + r"这一拍不再新增事件[^。!?!?]*[。!?!?]?", + "", + str(getattr(beat.event, "summary", "") or ""), + )) + beat_label = _reader_visible_anchor(str(getattr(beat, "beat_label", "") or "")) + if title and title in paragraph: + score += 10 + if summary and summary in paragraph: + score += 3 + if beat_label and beat_label in paragraph: + score += 6 + return score + + +def _paragraph_contains_event_anchor(paragraph: str, scene_beats: Sequence[SceneBeat]) -> bool: + for beat in scene_beats: + title = _reader_visible_anchor(str(getattr(beat.event, "title", "") or "")) + beat_label = _reader_visible_anchor(str(getattr(beat, "beat_label", "") or "")) + summary = _reader_visible_anchor(re.sub( + r"这一拍不再新增事件[^。!?!?]*[。!?!?]?", + "", + str(getattr(beat.event, "summary", "") or ""), + )) + summary = _compact_summary_anchor(summary, title=title) + if title and title in paragraph: + return True + if beat_label and beat_label in paragraph: + return True + if summary and summary in paragraph: + return True + return False + + +def _reader_visible_anchor(anchor: str) -> str: + anchor = str(anchor or "").strip() + if not anchor: + return "" + anchor = re.sub(r"\b[a-zA-Z_][A-Za-z0-9_]*\b", " ", anchor) + anchor = re.sub(r"\s+", " ", anchor) + anchor = re.sub(r"(?:\s*[·::/|_-]\s*){2,}", " · ", anchor) + anchor = re.sub(r"(?:\s*[·::/|_-]\s*\d+\s*)+$", "", anchor) + anchor = anchor.strip(" ·::/|_-") + latin_anchor_chars = set("abcdefghijklmnopqrstuvwxyz0123456789_:/\\- ") + if all(char in latin_anchor_chars for char in anchor): + return "" + return anchor + + +def _compact_label_anchor(label: str, *, title: str = "") -> str: + label = _reader_visible_anchor(label) + title = _reader_visible_anchor(title) + if not label: + return "" + label = re.sub(r"^(起势|逼近|转向|余波|回声)\s*[::·-]\s*", "", label).strip() + label = re.sub(r"(?:\s*[·::/|_-]\s*\d+\s*)+$", "", label).strip(" ·::/|_-") + generic_fragments = ["真正要转向", "这一拍留下来的余波", "把下一次公开代价推近"] + if title and title in label: + return title + if any(fragment in label for fragment in generic_fragments) and title: + return title + return label + + +def _compact_title_anchor(title: str, *, location: str = "") -> str: + title = _reader_visible_anchor(title) + location = _reader_visible_anchor(location) + if not title: + return "" + title = re.sub(r"(?:\s*[·::/|_-]\s*\d+\s*)+$", "", title).strip(" ·::/|_-") + parts = [part.strip(" ·::/|_-") for part in re.split(r"\s*·\s*", title) if part.strip(" ·::/|_-")] + generic_fragments = ["真正要转向", "说出口后的余波", "这一拍留下来的余波"] + if len(parts) > 1: + meaningful_parts = [ + part + for part in parts + if part != location and not any(fragment in part for fragment in generic_fragments) + ] + if meaningful_parts: + return meaningful_parts[0][:26] + if location and title == location: + return "" + return title[:26] + + +def _compact_summary_anchor(summary: str, *, title: str = "", world_title: str = "") -> str: + summary = _reader_visible_anchor(summary) + title = _reader_visible_anchor(title) + world_title = _reader_visible_anchor(world_title) + if not summary: + return "" + summary = re.sub(r"这一拍不再新增事件[^。!?!?]*[。!?!?]?", "", summary).strip() + summary = summary.replace("真正要转向的那句终于逼到眼前", " ").strip() + summary = re.sub(r"刚才没说透的态度、代价和退路都被逼到明处[。!?!?]?", " ", summary).strip() + if world_title: + summary = summary.replace(f"{world_title} 中,", "").replace(f"{world_title}中,", "") + if title: + summary = summary.replace(title, "").strip(" ,。·") + summary = re.sub(r"让人物进一步卷入[^。!?!?]*[。!?!?]?", "", summary).strip(" ,。·") + summary = re.sub(r"\s+", " ", summary).strip() + if summary in {"中", "中,", "中,"}: + return "" + if len(summary) > 24: + summary = summary[:24] + return summary + + +def _compact_anchor_line(parts: Sequence[str]) -> str: + compacted: List[str] = [] + for raw_part in parts: + part = _reader_visible_anchor(str(raw_part or "")) + part = re.sub(r"(?:\s*[·::/|_-]\s*\d+\s*)+$", "", part).strip(" ·::/|_-") + if not part: + continue + if len(part) > 26: + part = part[:26] + if any(part == existing or part in existing or existing in part for existing in compacted): + if not any(existing in part and len(part) < len(existing) for existing in compacted): + continue + compacted.append(part) + return " ".join(compacted[:4]).strip() + + +def _source_anchor_for_beat(paragraph: str, beat: SceneBeat) -> str: + title = _reader_visible_anchor(str(getattr(beat.event, "title", "") or "")) + beat_label = _reader_visible_anchor(str(getattr(beat, "beat_label", "") or "")) + summary = _reader_visible_anchor(re.sub( + r"这一拍不再新增事件[^。!?!?]*[。!?!?]?", + "", + str(getattr(beat.event, "summary", "") or ""), + )) + summary = _compact_summary_anchor(summary, title=title) + if title and title in paragraph: + return title + if beat_label and beat_label in paragraph: + return beat_label + if summary and summary in paragraph: + return summary + return "" + + +def _beat_for_paragraph(paragraph: str, scene_beats: Sequence[SceneBeat], *, fallback_index: int) -> SceneBeat: + for beat in scene_beats: + if _source_anchor_for_beat(paragraph, beat): + return beat + return scene_beats[min(fallback_index % len(scene_beats), len(scene_beats) - 1)] + + +def _missing_anchor_beats(paragraphs: Sequence[str], scene_beats: Sequence[SceneBeat]) -> List[SceneBeat]: + body = "\n\n".join(paragraphs) + missing: List[SceneBeat] = [] + for beat in scene_beats: + title = _reader_visible_anchor(str(getattr(beat.event, "title", "") or "")) + beat_label = _reader_visible_anchor(str(getattr(beat, "beat_label", "") or "")) + anchors = [anchor for anchor in (title, beat_label) if anchor] + if anchors and not any(anchor in body for anchor in anchors): + missing.append(beat) + return missing + + +def _trim_to_max_units( + paragraphs: Sequence[str], + *, + min_target_word_count: int, + max_target_word_count: int, + scene_beats: Sequence[SceneBeat], + remediation_actions: List[str], + protected_indexes: set[int] | None = None, +) -> List[str]: + trimmed = [paragraph for paragraph in paragraphs if paragraph and paragraph.strip()] + protected = set(protected_indexes or set()) + if max_target_word_count <= 0: + return trimmed + while story_text_unit_count("\n\n".join(trimmed)) > max_target_word_count and len(trimmed) > 3: + removable_indexes = [ + index + for index, paragraph in enumerate(trimmed) + if index not in {0, len(trimmed) - 1} + and index not in protected + and story_text_unit_count("\n\n".join(trimmed[:index] + trimmed[index + 1 :])) >= min_target_word_count + ] + if not removable_indexes: + break + unanchored_indexes = [ + index for index in removable_indexes if not _paragraph_contains_event_anchor(trimmed[index], scene_beats) + ] + candidate_indexes = unanchored_indexes or removable_indexes + remove_index = max( + candidate_indexes, + key=lambda index: ( + 1 if _is_exposition_paragraph(trimmed[index]) else 0, + -_paragraph_anchor_score(trimmed[index], scene_beats), + story_text_unit_count(trimmed[index]), + ), + ) + trimmed.pop(remove_index) + protected = { + index if index < remove_index else index - 1 + for index in protected + if index != remove_index + } + remediation_actions.append(f"length_gate_trim:{remove_index}") + return trimmed + + +def _drop_repeated_paragraphs_after_trim( + paragraphs: Sequence[str], + *, + min_target_word_count: int, + scene_beats: Sequence[SceneBeat], + remediation_actions: List[str], +) -> List[str]: + updated = [paragraph for paragraph in paragraphs if paragraph and paragraph.strip()] + if not scene_beats: + return updated + for _ in range(2): + bundle = _coverage_repetition_bundle(updated, scene_beats) + repeated_pairs = [ + dict(item or {}) + for item in list(bundle.get("semantic_paragraph_similarity_pairs") or []) + if float((item or {}).get("similarity", 0.0) or 0.0) >= 0.82 + ] + repeated_pairs.extend( + dict(item or {}) + for item in list(bundle.get("top_repeated_paragraph_pairs") or []) + if float((item or {}).get("similarity", 0.0) or 0.0) >= 0.45 + ) + if not repeated_pairs: + break + removed = False + for pair in repeated_pairs: + right_index = int(pair.get("right_paragraph_index", -1) or -1) + if right_index <= 0 or right_index >= len(updated) - 1: + continue + trial = updated[:right_index] + updated[right_index + 1 :] + if story_text_unit_count("\n\n".join(trial)) < min_target_word_count: + continue + updated = trial + remediation_actions.append(f"q03_final_repeated_paragraph_drop:{right_index}") + removed = True + break + if not removed: + break + return updated + + +def _paragraph_detail_count(paragraph: str) -> int: + return sum(str(paragraph or "").count(marker) for marker in DETAIL_MARKERS) + + +def _paragraph_action_count(paragraph: str) -> int: + return sum(str(paragraph or "").count(marker) for marker in ACTION_MARKERS) + + +def _low_detail_replace_index(paragraphs: Sequence[str], scene_beats: Sequence[SceneBeat]) -> int | None: + if not paragraphs: + return None + candidates = [ + index + for index, paragraph in enumerate(paragraphs) + if 0 < index < len(paragraphs) - 1 + and not _has_continuation_hook(paragraph) + ] + if not candidates: + candidates = [ + index + for index, paragraph in enumerate(paragraphs) + if index != len(paragraphs) - 1 + and not _has_continuation_hook(paragraph) + ] + if not candidates: + return None + return min( + candidates, + key=lambda index: ( + _paragraph_detail_count(paragraphs[index]), + _paragraph_action_count(paragraphs[index]), + _paragraph_anchor_score(paragraphs[index], scene_beats), + -story_text_unit_count(paragraphs[index]), + ), + ) + + +def _repair_detail_density_after_trim( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + min_target_word_count: int, + max_target_word_count: int, + remediation_actions: List[str], + target_density: float | None = None, + min_detail_count: int = 12, + max_attempts: int = 4, + fast_scene_detail_only: bool = False, +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = list(paragraphs) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + target_density = float(target_density if target_density is not None else CONTRACT_DETAIL_DENSITY_FLOOR + 0.006) + attempt = 0 + while attempt < max_attempts: + if fast_scene_detail_only: + lint_report = _detail_density_snapshot(updated) + current_units = int(lint_report.get("text_unit_count", 0) or 0) + else: + repaired = _rebuild_draft(updated, {}) + lint_report = lint_chapter_draft(repaired.body) + current_units = story_text_unit_count(repaired.body) + if ( + float(lint_report.get("concrete_detail_density", 0.0) or 0.0) >= target_density + and int(lint_report.get("detail_count", 0) or 0) >= min_detail_count + and current_units >= min_target_word_count + ): + break + if fast_scene_detail_only: + beat = scene_beats[min((chapter_index + attempt) % len(scene_beats), len(scene_beats) - 1)] + bridge = _detail_density_relief_paragraph( + world, + state_before, + beat, + variant_seed=2600 + chapter_index + attempt, + chapter_index=chapter_index, + ) + else: + repetition_bundle = _coverage_repetition_bundle(updated, scene_beats) + target_beats = _coverage_gap_target_beats(scene_beats, repetition_bundle) + beat = ( + target_beats[min(attempt % len(target_beats), len(target_beats) - 1)] + if target_beats + else scene_beats[min((attempt + chapter_index) % len(scene_beats), len(scene_beats) - 1)] + ) + bridge = ( + _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=2620 + attempt, + chapter_index=chapter_index, + ) + if target_beats + else _detail_density_relief_paragraph( + world, + state_before, + beat, + variant_seed=2600 + attempt, + chapter_index=chapter_index, + ) + ) + replacement = " ".join( + [ + bridge, + _detail_reinforcement_paragraph( + world, + beat, + chapter_index=chapter_index, + variant_seed=2650 + attempt, + ), + ] + ).strip() + replace_index = _low_detail_replace_index(updated, scene_beats) + if current_units < min_target_word_count or replace_index is None: + updated.insert(_hook_insert_index(updated), replacement) + remediation_actions.append(f"q05_final_detail_density_insert:{attempt}") + else: + updated[replace_index] = replacement + remediation_actions.append(f"q05_final_detail_density_replace:{replace_index}") + if not fast_scene_detail_only: + updated = _dedupe_repeated_sentences( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired_after_update = _rebuild_draft(updated, {}) + if ( + story_text_unit_count(repaired_after_update.body) > max_target_word_count + and story_text_unit_count(repaired_after_update.body) >= min_target_word_count + ): + updated = _trim_to_max_units( + updated, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + attempt += 1 + if fast_scene_detail_only: + updated = _dedupe_repeated_sentences( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + return updated + + +def _needs_final_repetition_repair(lint_report: dict[str, object], repetition_bundle: dict[str, object]) -> bool: + return ( + float(lint_report.get("repetition_score", 0.0) or 0.0) > 0.20 + or float(repetition_bundle.get("overall_repetition_pressure", 0.0) or 0.0) >= 0.38 + or float(repetition_bundle.get("semantic_paragraph_similarity_score", 0.0) or 0.0) >= 0.65 + or float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) >= 0.42 + or float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) >= 0.35 + or int(repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + or int(repetition_bundle.get("overcovered_beat_count", 0) or 0) >= 2 + ) + + +def _final_repetition_replace_index( + paragraphs: Sequence[str], + repetition_bundle: dict[str, object], + scene_beats: Sequence[SceneBeat], +) -> int | None: + pair_candidates = [ + int((item or {}).get("right_paragraph_index", -1) or -1) + for item in list(repetition_bundle.get("semantic_paragraph_similarity_pairs") or []) + if float((item or {}).get("similarity", 0.0) or 0.0) >= 0.60 + ] + pair_candidates.extend( + int((item or {}).get("right_paragraph_index", -1) or -1) + for item in list(repetition_bundle.get("top_repeated_paragraph_pairs") or []) + if float((item or {}).get("similarity", 0.0) or 0.0) >= 0.18 + ) + for index in pair_candidates: + if 0 < index < len(paragraphs) - 1: + return index + candidates = [ + index + for index, paragraph in enumerate(paragraphs) + if 0 < index < len(paragraphs) - 1 + and not _has_continuation_hook(paragraph) + ] + if not candidates: + return None + return min( + candidates, + key=lambda index: ( + _paragraph_anchor_score(paragraphs[index], scene_beats), + 0 if _is_exposition_paragraph(paragraphs[index]) else 1, + -story_text_unit_count(paragraphs[index]), + ), + ) + + +def _repair_repetition_after_final_detail( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + min_target_word_count: int, + max_target_word_count: int, + remediation_actions: List[str], + max_attempts: int = 6, +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = list(paragraphs) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + attempt = 0 + while attempt < max_attempts: + repaired = _rebuild_draft(updated, {}) + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(updated, scene_beats) + if not _needs_final_repetition_repair(lint_report, repetition_bundle): + break + target_beats = _expanded_coverage_target_beats(scene_beats, repetition_bundle) + beat = target_beats[min(attempt % len(target_beats), len(target_beats) - 1)] if target_beats else scene_beats[min(attempt, len(scene_beats) - 1)] + if ( + float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) >= 0.42 + or float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) >= 0.35 + or int(repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + ): + replacement = _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=2800 + attempt, + chapter_index=chapter_index, + ) + elif ( + float(lint_report.get("repetition_score", 0.0) or 0.0) > 0.20 + or float(repetition_bundle.get("overall_repetition_pressure", 0.0) or 0.0) >= 0.38 + ): + replacement = _lexical_repetition_relief_paragraph( + world, + state_before, + beat, + variant_seed=2800 + attempt, + chapter_index=chapter_index, + ) + else: + replacement = _paragraph_replacement( + world=world, + state_before=state_before, + beat=beat, + paragraph_index=2800 + attempt, + chapter_index=chapter_index, + previous_paragraphs=updated, + source_paragraph="", + ) + replace_index = _final_repetition_replace_index(updated, repetition_bundle, scene_beats) + if replace_index is None: + updated.insert(_hook_insert_index(updated), replacement) + remediation_actions.append(f"q03_final_repetition_insert:{attempt}") + else: + updated[replace_index] = replacement + remediation_actions.append(f"q03_final_repetition_replace:{replace_index}") + updated = _dedupe_repeated_sentences( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + updated = _drop_repeated_paragraphs_after_trim( + updated, + min_target_word_count=min_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + if story_text_unit_count("\n\n".join(updated)) > max_target_word_count: + updated = _trim_to_max_units( + updated, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + attempt += 1 + return updated + + +def _repair_dialogue_action_after_final_detail( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + min_target_word_count: int, + max_target_word_count: int, + remediation_actions: List[str], +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = list(paragraphs) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + target_ratio = 0.46 + attempt = 0 + while attempt < 4: + repaired = _rebuild_draft(updated, {}) + lint_report = lint_chapter_draft(repaired.body) + if ( + float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) >= target_ratio + and story_text_unit_count(repaired.body) >= min_target_word_count + ): + break + repetition_bundle = _coverage_repetition_bundle(updated, scene_beats) + target_beats = _coverage_gap_target_beats(scene_beats, repetition_bundle) + beat = ( + target_beats[min(attempt % len(target_beats), len(target_beats) - 1)] + if target_beats + else scene_beats[min((attempt + 1) % len(scene_beats), len(scene_beats) - 1)] + ) + pressure = " ".join( + [ + _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=3000 + attempt, + chapter_index=chapter_index, + ), + _action_pressure_paragraph(world, state_before, beat), + ] + ).strip() + candidates = [ + index + for index, paragraph in enumerate(updated) + if 0 < index < len(updated) - 1 + and not _has_continuation_hook(paragraph) + ] + replace_index = min( + candidates, + key=lambda index: ( + _paragraph_action_count(updated[index]), + 0 if _is_exposition_paragraph(updated[index]) else 1, + _paragraph_anchor_score(updated[index], scene_beats), + -story_text_unit_count(updated[index]), + ), + ) if candidates else None + if story_text_unit_count(repaired.body) < min_target_word_count or replace_index is None: + updated.insert(_hook_insert_index(updated), pressure) + remediation_actions.append(f"q04_final_dialogue_action_insert:{attempt}") + else: + updated[replace_index] = pressure + remediation_actions.append(f"q04_final_dialogue_action_replace:{replace_index}") + updated = _dedupe_repeated_sentences( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired_after_update = _rebuild_draft(updated, {}) + lint_after_update = lint_chapter_draft(repaired_after_update.body) + if float(lint_after_update.get("dialogue_plus_action_ratio", 0.0) or 0.0) < target_ratio: + stable_candidates = [ + index + for index in range(0, max(0, len(updated) - 1)) + if not _has_continuation_hook(updated[index]) + ] + inline_index = ( + max( + stable_candidates, + key=lambda index: ( + _paragraph_anchor_score(updated[index], scene_beats), + -_paragraph_action_count(updated[index]), + -story_text_unit_count(updated[index]), + ), + ) + if stable_candidates + else None + ) + if inline_index is None and replace_index is not None and replace_index < len(updated): + inline_index = replace_index + if inline_index is None: + inline_index = _low_detail_replace_index(updated, scene_beats) + if inline_index is not None: + updated[inline_index] = " ".join( + [ + updated[inline_index].rstrip(), + _compact_action_dialogue_sentence( + world, + state_before, + beat, + variant_seed=3100 + attempt, + ), + ] + ).strip() + remediation_actions.append(f"q04_final_compact_action_inline:{inline_index}") + repaired_after_update = _rebuild_draft(updated, {}) + lint_after_update = lint_chapter_draft(repaired_after_update.body) + if ( + story_text_unit_count(repaired_after_update.body) > max_target_word_count + and story_text_unit_count(repaired_after_update.body) >= min_target_word_count + ): + updated = _trim_to_max_units( + updated, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + attempt += 1 + return updated + + +def _repair_final_q04_micro( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + min_target_word_count: int, + max_target_word_count: int, + remediation_actions: List[str], +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = list(paragraphs) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + attempt = 0 + while attempt < 4: + repaired = _rebuild_draft(updated, {}) + lint_report = lint_chapter_draft(repaired.body) + current_units = story_text_unit_count(repaired.body) + exposition_threshold = 0.48 if current_units >= 1800 else 0.44 + if ( + float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) >= 0.43 + and float(lint_report.get("exposition_ratio", 0.0) or 0.0) <= exposition_threshold + and current_units >= min_target_word_count + ): + break + repetition_bundle = _coverage_repetition_bundle(updated, scene_beats) + target_beats = _coverage_gap_target_beats(scene_beats, repetition_bundle) + beat = ( + target_beats[min(attempt % len(target_beats), len(target_beats) - 1)] + if target_beats + else scene_beats[min((chapter_index + attempt) % len(scene_beats), len(scene_beats) - 1)] + ) + candidates = [ + index + for index, paragraph in enumerate(updated) + if index < len(updated) - 1 + and not _has_continuation_hook(paragraph) + ] + if not candidates: + candidates = [0] if updated else [] + if not candidates: + break + target_index = max( + candidates, + key=lambda index: ( + 1 if _is_exposition_paragraph(updated[index]) else 0, + _paragraph_anchor_score(updated[index], scene_beats), + -_paragraph_action_count(updated[index]), + ), + ) + updated[target_index] = " ".join( + [ + updated[target_index].rstrip(), + _compact_action_dialogue_sentence( + world, + state_before, + beat, + variant_seed=3300 + attempt + target_index * 13 + len(updated) * 17, + ), + ] + ).strip() + remediation_actions.append(f"q04_final_micro_inline:{target_index}") + updated = _dedupe_repeated_sentences( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + if story_text_unit_count("\n\n".join(updated)) > max_target_word_count: + updated = _trim_to_max_units( + updated, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + attempt += 1 + return updated + + +def _is_exposition_paragraph(paragraph: str) -> bool: + return ":" not in paragraph and "“" not in paragraph + + +def _short_dialogue_turn(state_before: NarrativeState, beat: SceneBeat, *, variant_index: int = 0) -> str: + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + counterpart = _actor_name(state_before, beat.event.actors[1]) if len(beat.event.actors) > 1 else "对面那人" + variants = [ + f'{actor_name}压低声音:“这句我不再绕开。”', + f'{counterpart}看着他:“那就把后果说实。”', + f'{actor_name}停了半拍:“我知道这一步不能只靠解释。”', + f'{counterpart}没有退:“别再把真话留一半。”', + ] + return variants[int(variant_index) % len(variants)] + + +def _dialogize_exposition_paragraphs( + paragraphs: Sequence[str], + *, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + attempts: int, + remediation_actions: List[str], +) -> List[str]: + updated = list(paragraphs) + if not scene_beats: + return updated + for attempt in range(max(0, attempts)): + candidates = [ + index + for index, paragraph in enumerate(updated) + if index != len(updated) - 1 and _is_exposition_paragraph(paragraph) + ] + if not candidates: + break + target_index = max(candidates, key=lambda index: story_text_unit_count(updated[index])) + beat = scene_beats[min(target_index % len(scene_beats), len(scene_beats) - 1)] + updated[target_index] = " ".join( + [ + updated[target_index].rstrip(), + _short_dialogue_turn(state_before, beat, variant_index=attempt + target_index), + ] + ).strip() + remediation_actions.append(f"q04_final_exposition_dialogize:{target_index}") + return updated + + +def _scrub_longform_suspicious_refrains( + paragraphs: Sequence[str], + *, + chapter_index: int, + remediation_actions: List[str], +) -> List[str]: + updated: List[str] = [] + for paragraph_index, paragraph in enumerate(paragraphs): + cleaned = str(paragraph or "") + replaced_any = False + for phrase, replacements in LONGFORM_SUSPICIOUS_REFRAIN_REPLACEMENTS.items(): + if phrase not in cleaned: + continue + replacement = replacements[(chapter_index + paragraph_index + len(updated)) % len(replacements)] + cleaned = cleaned.replace(phrase, replacement) + replaced_any = True + if replaced_any: + remediation_actions.append(f"q03_longform_refrain_scrub:{paragraph_index}") + updated.append(cleaned) + return updated + + +def _exposition_ratio_for_paragraphs(paragraphs: Sequence[str]) -> float: + cleaned = [paragraph for paragraph in paragraphs if str(paragraph or "").strip()] + if not cleaned: + return 0.0 + return sum(1 for paragraph in cleaned if _is_exposition_paragraph(paragraph)) / float(len(cleaned)) + + +def _dialogize_longform_exposition_surface( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + remediation_actions: List[str], + target_ratio: float = LONGFORM_STOP_READY_EXPOSITION_TARGET, + max_attempts: int = 8, +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = list(paragraphs) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + for attempt in range(max(0, max_attempts)): + if _exposition_ratio_for_paragraphs(updated) <= target_ratio: + break + candidates = [ + index + for index, paragraph in enumerate(updated) + if _is_exposition_paragraph(paragraph) and not _has_continuation_hook(paragraph) + ] + if not candidates: + candidates = [index for index, paragraph in enumerate(updated) if _is_exposition_paragraph(paragraph)] + if not candidates: + break + target_index = max( + candidates, + key=lambda index: ( + story_text_unit_count(updated[index]), + _paragraph_anchor_score(updated[index], scene_beats), + ), + ) + beat = scene_beats[(chapter_index + target_index + attempt) % len(scene_beats)] + updated[target_index] = " ".join( + [ + updated[target_index].rstrip(), + _compact_action_dialogue_sentence( + world, + state_before, + beat, + variant_seed=9700 + attempt + target_index * 31, + ), + ] + ).strip() + remediation_actions.append(f"q04_longform_surface_dialogize:{target_index}") + return updated + + +def _dialogue_scene_replacement_paragraph( + world: WorldBible, + state_before: NarrativeState, + beat: SceneBeat, + *, + variant_seed: int = 0, + chapter_index: int = 0, +) -> str: + return " ".join( + [ + scene_detail(world, beat, repeated=False, chapter_index=chapter_index), + compose_late_longform_compact_exchange( + world, + state_before, + beat, + repeated=True, + variant_offset=variant_seed, + ), + _compact_action_dialogue_sentence( + world, + state_before, + beat, + variant_seed=variant_seed + 17, + ), + ] + ).strip() + + +def _final_longform_q04_closeout( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + min_target_word_count: int, + max_target_word_count: int, + remediation_actions: List[str], + target_exposition_ratio: float = LONGFORM_STOP_READY_EXPOSITION_TARGET, + target_dialogue_ratio: float = LONGFORM_STOP_READY_DIALOGUE_TARGET, + max_attempts: int = 7, +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = [paragraph for paragraph in paragraphs if str(paragraph or "").strip()] + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + for attempt in range(max(0, max_attempts)): + lint_report = lint_chapter_draft("\n\n".join(updated)) + if ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) <= target_exposition_ratio + and float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) >= target_dialogue_ratio + and story_text_unit_count("\n\n".join(updated)) >= min_target_word_count + ): + break + candidates = [ + index + for index, paragraph in enumerate(updated) + if 0 < index < len(updated) - 1 + and _is_exposition_paragraph(paragraph) + and not _has_continuation_hook(paragraph) + ] + if not candidates: + candidates = [ + index + for index, paragraph in enumerate(updated) + if 0 < index < len(updated) - 1 and not _has_continuation_hook(paragraph) + ] + if not candidates: + break + target_index = max( + candidates, + key=lambda index: ( + 1 if _is_exposition_paragraph(updated[index]) else 0, + story_text_unit_count(updated[index]), + _paragraph_anchor_score(updated[index], scene_beats), + ), + ) + beat = _beat_for_paragraph(updated[target_index], scene_beats, fallback_index=target_index + attempt) + replacement = _dialogue_scene_replacement_paragraph( + world, + state_before, + beat, + variant_seed=11400 + attempt * 41 + target_index, + chapter_index=chapter_index, + ) + updated[target_index] = replacement + remediation_actions.append(f"q04_final_longform_closeout_replace:{target_index}") + protected_indexes = {target_index} + if story_text_unit_count("\n\n".join(updated)) < min_target_word_count: + filler_beat = scene_beats[(chapter_index + attempt + 29) % len(scene_beats)] + insert_index = _hook_insert_index(updated) + updated.insert( + insert_index, + _dialogue_scene_replacement_paragraph( + world, + state_before, + filler_beat, + variant_seed=11480 + attempt, + chapter_index=chapter_index, + ), + ) + protected_indexes = { + index + 1 if index >= insert_index else index + for index in protected_indexes + } + protected_indexes.add(insert_index) + remediation_actions.append(f"length_gate_q04_final_longform_closeout:{attempt}") + updated = _scrub_longform_suspicious_refrains( + updated, + chapter_index=chapter_index + attempt, + remediation_actions=remediation_actions, + ) + updated = _dedupe_repeated_sentences( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + if story_text_unit_count("\n\n".join(updated)) > max_target_word_count: + updated = _trim_to_max_units( + updated, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + protected_indexes=protected_indexes, + ) + return updated + + +def _final_longform_surface_reconcile( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + min_target_word_count: int, + max_target_word_count: int, + remediation_actions: List[str], + max_attempts: int = 3, +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = [paragraph for paragraph in paragraphs if str(paragraph or "").strip()] + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + for attempt in range(max(0, max_attempts)): + lint_report = lint_chapter_draft("\n\n".join(updated)) + repetition_bundle = _coverage_repetition_bundle(updated, scene_beats) + q03_dirty = _longform_surface_q03_needs_repair(lint_report, repetition_bundle) + q04_dirty = ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) > LONGFORM_STOP_READY_EXPOSITION_TARGET + or float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < LONGFORM_STOP_READY_DIALOGUE_TARGET + ) + if ( + not q03_dirty + and not q04_dirty + and story_text_unit_count("\n\n".join(updated)) >= min_target_word_count + ): + break + if q03_dirty: + updated = _final_longform_q03_closeout( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=2, + ) + updated = _final_longform_q04_closeout( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=5, + ) + lint_after_q04 = lint_chapter_draft("\n\n".join(updated)) + if ( + float(lint_after_q04.get("exposition_ratio", 0.0) or 0.0) > LONGFORM_STOP_READY_EXPOSITION_TARGET + or float(lint_after_q04.get("dialogue_plus_action_ratio", 0.0) or 0.0) < LONGFORM_STOP_READY_DIALOGUE_TARGET + ): + beat = scene_beats[(chapter_index + attempt + 41) % len(scene_beats)] + insert_index = _hook_insert_index(updated) + updated.insert( + insert_index, + _dialogue_scene_replacement_paragraph( + world, + state_before, + beat, + variant_seed=11740 + attempt, + chapter_index=chapter_index, + ), + ) + remediation_actions.append(f"q04_final_surface_reconcile_insert:{attempt}") + if story_text_unit_count("\n\n".join(updated)) > max_target_word_count: + updated = _trim_to_max_units( + updated, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + protected_indexes={insert_index}, + ) + remediation_actions.append(f"surface_final_longform_reconcile:{attempt}") + return updated + + +def _force_longform_q04_paragraph_mix( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + min_target_word_count: int, + remediation_actions: List[str], + max_attempts: int = 3, +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = [paragraph for paragraph in paragraphs if str(paragraph or "").strip()] + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + for attempt in range(max(0, max_attempts)): + lint_report = lint_chapter_draft("\n\n".join(updated)) + if ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) <= LONGFORM_STOP_READY_EXPOSITION_TARGET + and float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) >= LONGFORM_STOP_READY_DIALOGUE_TARGET + and story_text_unit_count("\n\n".join(updated)) >= min_target_word_count + ): + break + candidates = [ + index + for index, paragraph in enumerate(updated) + if index < len(updated) - 1 and _is_exposition_paragraph(paragraph) + ] + if not candidates: + candidates = [ + index + for index, paragraph in enumerate(updated) + if index < len(updated) - 1 and "“" not in paragraph + ] + if not candidates: + break + target_index = max(candidates, key=lambda index: story_text_unit_count(updated[index])) + beat = _beat_for_paragraph(updated[target_index], scene_beats, fallback_index=target_index + attempt) + updated[target_index] = _dialogue_scene_replacement_paragraph( + world, + state_before, + beat, + variant_seed=11840 + attempt * 37 + target_index, + chapter_index=chapter_index, + ) + remediation_actions.append(f"q04_force_paragraph_mix_replace:{target_index}") + if story_text_unit_count("\n\n".join(updated)) < min_target_word_count: + filler_beat = scene_beats[(chapter_index + attempt + 47) % len(scene_beats)] + updated.insert( + _hook_insert_index(updated), + _dialogue_scene_replacement_paragraph( + world, + state_before, + filler_beat, + variant_seed=11890 + attempt, + chapter_index=chapter_index, + ), + ) + remediation_actions.append(f"q04_force_paragraph_mix_refloor:{attempt}") + return updated + + +def _inline_longform_detail_surface_topup( + paragraphs: Sequence[str], + *, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + remediation_actions: List[str], + target_density: float = 0.065, + max_attempts: int = 4, +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = list(paragraphs) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + for attempt in range(max(0, max_attempts)): + lint_report = lint_chapter_draft("\n\n".join(updated)) + if float(lint_report.get("concrete_detail_density", 0.0) or 0.0) >= target_density: + break + candidates = [ + index + for index, paragraph in enumerate(updated) + if index < len(updated) - 1 and not _has_continuation_hook(paragraph) + ] + if not candidates: + candidates = list(range(0, max(0, len(updated) - 1))) + if not candidates: + break + target_index = candidates[(chapter_index + attempt) % len(candidates)] + beat = scene_beats[(chapter_index + target_index + attempt) % len(scene_beats)] + location = beat.event.location or "眼前这一处" + fragments = [ + f"{location}边的杯沿、门框、纸页、灯影和鞋底水声一起晃了一下。", + f"窗纸冷光贴过桌沿,衣袖、指节、茶气和香灰都在那一瞬变得清楚。", + f"门影压着木板轻响,杯底水痕、纸页折角和檐下风声没有散开。", + f"灯芯短短一爆,案角、袖口、窗缝和地砖上的灰都被照得更近。", + ] + updated[target_index] = " ".join( + [ + updated[target_index].rstrip(), + fragments[(chapter_index + attempt + target_index) % len(fragments)], + ] + ).strip() + remediation_actions.append(f"q05_longform_surface_inline_topup:{target_index}") + return updated + + +def _longform_surface_q03_needs_repair( + lint_report: dict[str, object], + repetition_bundle: dict[str, object], +) -> bool: + return ( + float(lint_report.get("repetition_score", 0.0) or 0.0) > 0.20 + or float(repetition_bundle.get("semantic_paragraph_similarity_score", 0.0) or 0.0) >= 0.84 + or float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) > 0.42 + or float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) > 0.35 + or int(repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + or int(repetition_bundle.get("overcovered_beat_count", 0) or 0) >= 2 + or int(repetition_bundle.get("suspicious_refrain_count", 0) or 0) >= 2 + ) + + +def _repair_longform_surface_issue_mix_guard( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + min_target_word_count: int, + max_target_word_count: int, + remediation_actions: List[str], + max_attempts: int = 5, +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = list(paragraphs) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + for attempt in range(max(0, max_attempts)): + updated = _scrub_longform_suspicious_refrains( + updated, + chapter_index=chapter_index, + remediation_actions=remediation_actions, + ) + updated = _dialogize_longform_exposition_surface( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + max_attempts=4, + ) + repaired = _rebuild_draft(updated, {}) + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(updated, scene_beats) + q04_clean = float(lint_report.get("exposition_ratio", 0.0) or 0.0) <= LONGFORM_STOP_READY_EXPOSITION_TARGET + q03_clean = not _longform_surface_q03_needs_repair(lint_report, repetition_bundle) + if q03_clean and q04_clean and story_text_unit_count(repaired.body) >= min_target_word_count: + break + + coverage_pressure = ( + float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) > 0.42 + or float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) > 0.35 + or int(repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + ) + target_beats = ( + _expanded_coverage_target_beats(scene_beats, repetition_bundle, limit=6) + if coverage_pressure + else _coverage_gap_target_beats(scene_beats, repetition_bundle) + ) + beat = ( + target_beats[min(attempt % len(target_beats), len(target_beats) - 1)] + if target_beats + else scene_beats[(chapter_index + attempt) % len(scene_beats)] + ) + if coverage_pressure: + replacement_paragraphs = ( + _coverage_gap_surface_paragraphs( + world, + state_before, + target_beats, + variant_seed=9800 + attempt * 43, + chapter_index=chapter_index, + max_count=4, + ) + if len(target_beats) >= 2 + else [ + _coverage_anchor_echo_paragraph( + world, + state_before, + beat, + variant_seed=9800 + attempt, + chapter_index=chapter_index, + ) + ] + ) + else: + lexical_pressure = ( + float(lint_report.get("repetition_score", 0.0) or 0.0) > 0.20 + or float(repetition_bundle.get("semantic_paragraph_similarity_score", 0.0) or 0.0) >= 0.84 + or float(repetition_bundle.get("n_gram_repetition_score", 0.0) or 0.0) >= 0.18 + or int(repetition_bundle.get("suspicious_refrain_count", 0) or 0) >= 2 + ) + replacement_paragraphs = [ + ( + _lexical_repetition_relief_paragraph( + world, + state_before, + beat, + variant_seed=9800 + attempt * 53, + chapter_index=chapter_index, + ) + if lexical_pressure + else compose_late_longform_compact_exchange( + world, + state_before, + beat, + repeated=True, + variant_offset=9800 + attempt, + ) + ) + ] + replacement_paragraphs = _scrub_longform_suspicious_refrains( + replacement_paragraphs, + chapter_index=chapter_index + attempt, + remediation_actions=remediation_actions, + ) + if len(replacement_paragraphs) > 1: + replace_candidates = sorted( + [ + index + for index, paragraph in enumerate(updated) + if 0 < index < len(updated) - 1 and not _has_continuation_hook(paragraph) + ], + key=lambda index: ( + _paragraph_anchor_score(updated[index], scene_beats), + 0 if _is_exposition_paragraph(updated[index]) else 1, + -story_text_unit_count(updated[index]), + ), + ) + for offset, replacement in enumerate(replacement_paragraphs): + if offset < len(replace_candidates): + replace_index = replace_candidates[offset] + updated[replace_index] = replacement + remediation_actions.append(f"q03_longform_surface_coverage_replace:{replace_index}") + else: + insert_index = _hook_insert_index(updated) + updated.insert(insert_index, replacement) + remediation_actions.append(f"q03_longform_surface_coverage_insert:{attempt}:{offset}") + else: + replacement = replacement_paragraphs[0] if replacement_paragraphs else "" + replace_index = _final_repetition_replace_index(updated, repetition_bundle, scene_beats) + if replace_index is None: + updated.insert(_hook_insert_index(updated), replacement) + remediation_actions.append(f"q03_longform_surface_insert:{attempt}") + else: + updated[replace_index] = replacement + remediation_actions.append(f"q03_longform_surface_replace:{replace_index}") + updated = _dedupe_repeated_sentences( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + if story_text_unit_count("\n\n".join(updated)) > max_target_word_count: + updated = _trim_to_max_units( + updated, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + updated = _dialogize_longform_exposition_surface( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + max_attempts=6, + ) + return updated + + +def _final_longform_q03_closeout( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + min_target_word_count: int, + max_target_word_count: int, + remediation_actions: List[str], + max_attempts: int = 4, +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = [paragraph for paragraph in paragraphs if str(paragraph or "").strip()] + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + for attempt in range(max(0, max_attempts)): + repaired = _rebuild_draft(updated, {}) + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(updated, scene_beats) + if ( + not _longform_surface_q03_needs_repair(lint_report, repetition_bundle) + and story_text_unit_count(repaired.body) >= min_target_word_count + ): + break + coverage_pressure = ( + float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) > 0.42 + or float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) > 0.35 + or int(repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + or int(repetition_bundle.get("overcovered_beat_count", 0) or 0) >= 2 + ) + target_beats = ( + _expanded_coverage_target_beats(scene_beats, repetition_bundle, limit=6) + if coverage_pressure + else _coverage_gap_target_beats(scene_beats, repetition_bundle) + ) + beat = ( + target_beats[min(attempt % len(target_beats), len(target_beats) - 1)] + if target_beats + else scene_beats[(chapter_index + attempt) % len(scene_beats)] + ) + semantic_pressure = float(repetition_bundle.get("semantic_paragraph_similarity_score", 0.0) or 0.0) >= 0.80 + if coverage_pressure: + replacement = _coverage_anchor_echo_paragraph( + world, + state_before, + beat, + variant_seed=11200 + attempt * 47, + chapter_index=chapter_index, + ) + elif semantic_pressure: + replacement = _semantic_repetition_breaker_paragraph( + world, + state_before, + beat, + variant_seed=11200 + attempt * 47, + chapter_index=chapter_index, + ) + else: + replacement = _lexical_repetition_relief_paragraph( + world, + state_before, + beat, + variant_seed=11200 + attempt * 47, + chapter_index=chapter_index, + ) + replacement = _scrub_longform_suspicious_refrains( + [replacement], + chapter_index=chapter_index + attempt, + remediation_actions=remediation_actions, + )[0] + replace_index = _final_repetition_replace_index(updated, repetition_bundle, scene_beats) + if replace_index is None: + candidates = [ + index + for index, paragraph in enumerate(updated) + if 0 < index < len(updated) - 1 and not _has_continuation_hook(paragraph) + ] + replace_index = max( + candidates, + key=lambda index: story_text_unit_count(updated[index]), + ) if candidates else None + if replace_index is None or story_text_unit_count(repaired.body) < min_target_word_count: + updated.insert(_hook_insert_index(updated), replacement) + remediation_actions.append(f"q03_final_longform_closeout_insert:{attempt}") + else: + updated[replace_index] = replacement + remediation_actions.append(f"q03_final_longform_closeout_replace:{replace_index}") + updated = _dedupe_repeated_sentences( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + updated = _drop_repeated_paragraphs_after_trim( + updated, + min_target_word_count=min_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + if story_text_unit_count("\n\n".join(updated)) > max_target_word_count: + updated = _trim_to_max_units( + updated, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + return updated + + +def _repair_longform_stop_ready_dialogue_guard( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], + min_target_word_count: int, + max_target_word_count: int, + remediation_actions: List[str], + target_ratio: float = LONGFORM_STOP_READY_DIALOGUE_TARGET, + max_attempts: int = 3, +) -> List[str]: + if not scene_beats: + return list(paragraphs) + updated = list(paragraphs) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + used_replace_indexes: set[int] = set() + for attempt in range(max(0, max_attempts)): + repaired = _rebuild_draft(updated, {}) + lint_report = lint_chapter_draft(repaired.body) + if ( + float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) >= target_ratio + and story_text_unit_count(repaired.body) >= min_target_word_count + ): + break + beat = scene_beats[(chapter_index + attempt) % len(scene_beats)] + compact_exchange = compose_late_longform_compact_exchange( + world, + state_before, + beat, + repeated=True, + variant_offset=9000 + attempt * 29, + ) + candidate_indexes = [ + index + for index, paragraph in enumerate(updated) + if index not in used_replace_indexes + and 0 < index < len(updated) - 1 + and not _has_continuation_hook(paragraph) + ] + if story_text_unit_count(repaired.body) < min_target_word_count or not candidate_indexes: + updated.insert(_hook_insert_index(updated), compact_exchange) + remediation_actions.append(f"q04_longform_stop_ready_dialogue_insert:{attempt}") + else: + replace_index = max( + candidate_indexes, + key=lambda index: ( + 1 if _is_exposition_paragraph(updated[index]) else 0, + 1 if "“" not in updated[index] else 0, + -_paragraph_action_count(updated[index]), + story_text_unit_count(updated[index]), + ), + ) + updated[replace_index] = compact_exchange + used_replace_indexes.add(replace_index) + remediation_actions.append(f"q04_longform_stop_ready_dialogue_replace:{replace_index}") + updated = _dedupe_repeated_sentences( + updated, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + if story_text_unit_count("\n\n".join(updated)) > max_target_word_count: + updated = _trim_to_max_units( + updated, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + return updated + + +def _sensory_variation_paragraph( + world: WorldBible, + beat: SceneBeat, + *, + variant_seed: int = 0, + chapter_index: int = 0, +) -> str: + location = beat.event.location or "眼前这一处" + scene_function = SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + event_seed = _beat_seed(beat, chapter_index=chapter_index, extra=variant_seed) + variants = [ + f"{location}里的光线有点发冷,边角却积着旧尘、潮气和说不清来源的细碎响动。连门边那一下轻轻回弹的动静,都把{scene_function}里该说破的东西照得更分明。", + f"{location}没有立刻安静下来,反而能听见更琐碎的声响一层层往外浮。脚边拖过去的风、桌沿残着的水痕和空气里那点发苦的味道,把场面压出了新的棱角。", + f"{location}里最先变得清楚的不是谁的脸色,而是那些平时容易被忽略的小东西。灯影偏了一寸,器物碰出一点轻响,连空气里那股淡淡的金属味都把心思逼得更近了。", + f"{location}里的回声并不均匀,像有人故意把每一点门响、风声和纸页摩擦都留在了人心最不肯退的地方,让{scene_function}的后劲慢慢逼出来。", + f"{location}的空气带着潮意,灯下那一圈暗影却反而更清。桌角、窗缝、鞋底擦过地面的轻响全被拖长了,像在替这一步{scene_function}添新的重量。", + f"{location}里先变得具体起来的是门影、器物和人身上那点没收住的动作,连最轻的风声和桌沿回响都把{scene_function}往更硬的一侧推近。", + f"{location}没有替谁掩住后劲,反而让灯火、冷气、纸页和脚步里那点细碎震动一起把{scene_function}照得更难回避。", + ] + return variants[event_seed % len(variants)] + + +def _lexical_repetition_relief_paragraph( + world: WorldBible, + state_before: NarrativeState, + beat: SceneBeat, + *, + variant_seed: int = 0, + chapter_index: int = 0, +) -> str: + location = beat.event.location or "眼前这一处" + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + counterpart = _actor_name(state_before, beat.event.actors[1]) if len(beat.event.actors) > 1 else "对面那人" + focus = _scene_focus_label(beat) or SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + seed = _beat_seed(beat, chapter_index=chapter_index, extra=variant_seed) + variants = [ + f"{location}外忽然落下一串细响,檐水、竹帘、石缝、灯影、纸页和掌心里的凉意各自分开。{actor_name}把衣袖重新拢紧,低声道:“我听见了,也会照着做。”{counterpart}没有替他让路,只把目光停在门框旁那道冷光里。", + f"远处更鼓短短一震,余音沿着砖缝、窗缝和案角散开,连杯沿上的雨痕、茶气和香灰都比方才清楚。{counterpart}看着{actor_name}:“别再把{focus}藏进半句话里。”{actor_name}点了一下头,脚步终于没有往后撤。", + f"{location}边的铜扣轻轻一碰,暗纹、尘粒、木板、纸页和衣袖里的寒意同时露出来。{actor_name}先按住呼吸,再把声音压低:“这回我接住。”{counterpart}没有应得太快,只让桌沿那点停顿把后果钉实。", + f"{location}里先响的是纸页被推开的声音,随后才是门缝里的风。{counterpart}抬手挡住灯影:“别用同一句话绕。”{actor_name}把掌心贴上桌沿,答得很慢:“那我换一种说法,也换一种做法。”", + f"窗边那道冷光偏了一寸,照出杯底水痕和地砖缝里的灰。{actor_name}没有再从{focus}上退开,只把声音压低:“我现在往前走。”{counterpart}看着他,把那半步空出来。", + f"{location}边的脚步忽然停住,衣袖擦过门框时带出一声很轻的响。{counterpart}问:“这次你要怎么接?”{actor_name}先看了一眼案角,才说:“不靠解释,靠我接下来的动作。”", + f"灯芯短短一爆,纸页、杯沿和窗纸都跟着晃了一下。{actor_name}把原本要重复的那句话咽回去,改口道:“我先做给你看。”{counterpart}没有笑,只把路让出半寸。", + f"{location}里的风声绕过桌角,带起一点茶气和旧木味。{counterpart}没有再逼问,只把目光落在{actor_name}手上;那只手终于离开原处,朝{focus}真正压来的方向伸过去。", + ] + return variants[seed % len(variants)] + + +def _semantic_repetition_breaker_paragraph( + world: WorldBible, + state_before: NarrativeState, + beat: SceneBeat, + *, + variant_seed: int = 0, + chapter_index: int = 0, +) -> str: + location = beat.event.location or "眼前这一处" + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + counterpart = _actor_name(state_before, beat.event.actors[1]) if len(beat.event.actors) > 1 else "对面那人" + focus = _scene_focus_label(beat) or SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + seed = _beat_seed(beat, chapter_index=chapter_index, extra=variant_seed) + object_pool = [ + ("门轴", "杯底水痕", "纸页折角"), + ("窗缝冷光", "鞋尖灰尘", "袖口暗纹"), + ("桌沿旧痕", "灯座微响", "掌心凉意"), + ("栏杆阴影", "木板细纹", "衣摆尘色"), + ("器物边缘", "风里的潮味", "指节轻响"), + ] + first, second, third = object_pool[seed % len(object_pool)] + variants = [ + f"{first}先响了一下,{second}和{third}随即把{location}分成了几块不一样的冷色。{actor_name}没有沿着上一句往下说,只抬手指向{focus}最难遮住的地方:“从这里重新算。” {counterpart}看了一眼那处细节,终于把脚步停稳。", + f"{location}里忽然多出一层很轻的动静:{first}擦过影子,{second}压住回声,{third}把人的呼吸照得更近。{counterpart}问:“你现在要认哪一件?” {actor_name}没有复述旧话,只答:“认这一件,也认它后面会追来的账。”", + f"{actor_name}把目光从{counterpart}脸上移开,转而看住{first}、{second}和{third}。那几处小东西让{focus}不再像一句解释,而像当场摆出来的证物;他低声道:“不用绕了,先从这处落笔。”", + f"{location}没有再靠同一种停顿撑着。{first}把风声截短,{second}压住桌边那点反光,{third}让{counterpart}的沉默换了方向。{actor_name}往前半步:“我换一种做法,你看这一处。”", + ] + return variants[(seed // 5) % len(variants)] + + +def _detail_density_relief_paragraph( + world: WorldBible, + state_before: NarrativeState, + beat: SceneBeat, + *, + variant_seed: int = 0, + chapter_index: int = 0, +) -> str: + location = beat.event.location or "眼前这一处" + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + counterpart = _actor_name(state_before, beat.event.actors[1]) if len(beat.event.actors) > 1 else "对面那人" + seed = _beat_seed(beat, chapter_index=chapter_index, extra=variant_seed) + variants = [ + f"{location}的灯影落在纸页、杯沿、门框、窗缝和案角上,雨痕、茶气、香灰、衣袖和木板纹路都清楚起来。{actor_name}抬手按住桌沿,低声道:“我不会再只说一半。”{counterpart}看着他,脚步没有退。", + f"风从{location}边掠过去,檐下的雨、门后的影、阶前的纸页和杯沿的冷光一起晃了晃。{counterpart}把衣袖收回去,声音很轻:“那就照实往下走。”{actor_name}握紧掌心,终于点头。", + f"{location}里那盏灯照着案角、门框、窗纸、器物和衣摆,连茶香、雨声、纸页摩擦和鞋底擦过木板的细响都没有散。{actor_name}偏头看向{counterpart}:“我接得住。”", + ] + return variants[seed % len(variants)] + + +def _dialogic_opening_suffix(state_before: NarrativeState, beat: SceneBeat) -> str: + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + return f"{actor_name}心里先有了一句没出口的话:“真要走到这里,我也不能再装作什么都没发生。”" + + +def _strong_hook_line( + world: WorldBible, + scene_plan: ScenePlan, + scene_beats: Sequence[SceneBeat], + *, + chapter_index: int = 0, +) -> str: + hook = realize_hook( + world, + scene_plan.ending_hook, + scene_beats[-1].event.scene_function, + chapter_index=chapter_index, + ).strip() + if _has_continuation_hook(hook): + return hook + return f"{hook.rstrip('。')}。下一次开口前,真正追上来的那一句话还没有散。" + + +def _sentence_variation( + world: WorldBible, + state_before: NarrativeState, + beat: SceneBeat, + *, + variant_index: int, + chapter_index: int = 0, +) -> str: + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + counterpart = _actor_name(state_before, beat.event.actors[1]) if len(beat.event.actors) > 1 else "对面那人" + location = beat.event.location or "眼前这一处" + focus = _scene_focus_label(beat) + detail_variants = [ + "纸页边缘", + "门框冷光", + "杯沿水痕", + "窗纸阴影", + "桌沿木纹", + "衣袖摩擦声", + ] + detail = detail_variants[(int(variant_index) + int(chapter_index)) % len(detail_variants)] + variants = [ + f"{actor_name}没有立刻把那句更重的话推出去,只先看了{counterpart}一眼,像在判断这一步究竟还能不能一起往前。", + f"{location}里的声响并没有帮谁遮掩,反而把{actor_name}心里那点迟疑一点点逼到了明处。", + f"{counterpart}并不急着替他收场,只把沉默稳稳压住,让那句本该被躲开的真话继续留在两人之间。", + f"{actor_name}知道自己现在多退半步,后面就要拿更大的代价把这半步补回来。", + f"{location}里最难受的不是风声,而是那句已经说到一半却不能再收回去的话。", + f"{counterpart}抬眼时没有给他任何松动的余地,像是在提醒这一次谁都别想再只留一半真话。", + f"{actor_name}先把{focus}压在喉间,像明明知道它已经到了嘴边,却还想替自己多留半寸退路。", + f"{counterpart}没有替{actor_name}把这一步讲圆,只让{location}里的回声慢慢逼近,像逼人把{focus}认得更彻底。", + f"{location}边最先绷紧的不是谁的语气,而是{actor_name}和{counterpart}都知道{focus}已经不能再只停在半句上。", + f"{actor_name}抬眼时先碰上的是{counterpart}的沉默,那种不肯后退的静反倒把{focus}一步步推得更近。", + f"{counterpart}把那点迟疑留在眼底,没有替谁遮过去,像故意让{focus}在{location}里自己长出更重的后劲。", + f"{location}里的灯影、脚步和冷气都没替谁分担,反而把{focus}里最难认的那一层留在了每个人呼吸边上。", + f"{actor_name}没有急着把后半句补齐,只让指尖在{location}边那一点冷意上停了停,像要先确认自己到底还敢不敢认{focus}。", + f"{counterpart}把视线稳稳落在{actor_name}脸上,没有给场面多余的缓冲,像是非得逼着这一步{focus}在灯影底下见真章。", + f"{location}边的门框、回声和那点没收住的呼吸一起压上来,像连{focus}都被逼得只能往更明处走。", + f"{actor_name}把手停在{detail}旁,先看清自己还能退到哪里,才把{focus}里最难认的那一点慢慢放到桌面上。", + f"{counterpart}没有加重语气,只把{detail}旁那点停顿留出来,逼得{actor_name}自己决定还要不要继续绕开{focus}。", + f"{location}看似先静了一瞬,可真正不肯退开的,是{actor_name}和{counterpart}都知道{focus}已经回不到还能装作无事的那边。", + f"{actor_name}听见那句追问以后没有立刻应声,只让目光沿着{location}边那一点冷光停住,像在承认{focus}迟早得由自己接回去。", + f"{counterpart}先收住了动作,却没有把锋利也一起收回去,反而让{focus}顺着那点安静更慢、更硬地逼到眼前。", + ] + return variants[int(variant_index) % len(variants)] + + +def _dedupe_repeated_sentences( + paragraphs: Sequence[str], + *, + world: WorldBible, + state_before: NarrativeState, + scene_beats: Sequence[SceneBeat], +) -> List[str]: + if not scene_beats: + return list(paragraphs) + seen_sentences: List[str] = [] + rewritten: List[str] = [] + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + for paragraph_index, paragraph in enumerate(paragraphs): + beat = scene_beats[min(paragraph_index % len(scene_beats), len(scene_beats) - 1)] + segments = [segment.strip() for segment in SENTENCE_BOUNDARY_PATTERN.split(paragraph) if segment.strip()] + updated_segments: List[str] = [] + for sentence_index, sentence in enumerate(segments): + normalized = _normalize(sentence) + similar_seen = any(_sentence_similarity(normalized, prior) >= 0.72 for prior in seen_sentences if len(prior) >= 14) + if len(normalized) >= 14 and (normalized in seen_sentences or similar_seen): + sentence = _sentence_variation( + world, + state_before, + beat, + variant_index=chapter_index * 31 + paragraph_index * 7 + sentence_index, + chapter_index=chapter_index, + ) + normalized = _normalize(sentence) + if normalized: + seen_sentences.append(normalized) + updated_segments.append(sentence) + rewritten.append("".join(updated_segments).strip()) + return [paragraph for paragraph in rewritten if paragraph] -def _beat_variation_paragraph(world: WorldBible, state_before: NarrativeState, beat: SceneBeat) -> str: - if len(beat.event.actors) < 2: - actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" - return " ".join( - [ - scene_atmosphere(world, beat), - f"{actor_name}把目光压回眼前那一点光影里,像是先替自己把最难认的那句话按住。", - scene_detail(world, beat, repeated=True), - ] - ) - return " ".join( - [ - scene_atmosphere(world, beat), - compose_emotion_action(world, beat, repeated=True), - scene_detail(world, beat, repeated=True), - compose_dialogue(world, state_before, beat, repeated=True), - ] +def _length_target_bounds(*, draft: ChapterDraft, render_spec: SceneRenderSpec | None) -> tuple[int, int, int]: + target = int( + (render_spec.target_word_count if render_spec is not None else 0) + or draft.metadata.get("target_word_count") + or 2000 + ) + minimum = int( + (render_spec.min_target_word_count if render_spec is not None else 0) + or draft.metadata.get("min_target_word_count") + or max(200, target - 200) + ) + maximum = int( + (render_spec.max_target_word_count if render_spec is not None else 0) + or draft.metadata.get("max_target_word_count") + or max(target, target + 200) ) + if target >= 1800: + minimum = max(minimum, 1840) + maximum = max(maximum, minimum + 180) + return target, minimum, maximum -def _dialogue_pressure_paragraph(world: WorldBible, state_before: NarrativeState, beat: SceneBeat) -> str: - if len(beat.event.actors) < 2: - actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" - return " ".join( - [ - scene_atmosphere(world, beat), - f"{actor_name}低声道:“我先把这句话逼到明处,不再让它只在心里兜圈。”", - scene_detail(world, beat, repeated=True), - ] - ) - return " ".join( - [ - compose_dialogue(world, state_before, beat, repeated=False), - compose_emotion_action(world, beat, repeated=False), - ] - ) +def _hook_insert_index(paragraphs: Sequence[str]) -> int: + if paragraphs and _has_continuation_hook(paragraphs[-1]): + return max(0, len(paragraphs) - 1) + return len(paragraphs) -def _detail_reinforcement_paragraph(world: WorldBible, beat: SceneBeat) -> str: - location = beat.event.location or "眼前这一处" - return " ".join( - [ - scene_atmosphere(world, beat), - scene_detail(world, beat, repeated=False), - f"{location}里的风、门边回下来的轻响和衣角擦过去的细碎动静,一下子把人心里那点迟疑照得更清。", - ] - ) +def _expansion_reflection(state_before: NarrativeState, beat: SceneBeat, *, variant_index: int = 0) -> str: + actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" + counterpart = _actor_name(state_before, beat.event.actors[1]) if len(beat.event.actors) > 1 else "对面那人" + variants = [ + f"{actor_name}把手指压在案角,低声道:“这句一旦认下去,就不只落在我一个人身上。” {counterpart}没有躲开,只把那层沉默更稳地接住了。", + f"{actor_name}抬眼时迟了半拍,像终于承认开口以后{counterpart}也得一起承担。{counterpart}只回了一句:“你既然明白,就别再把后果说轻。”", + f"{actor_name}把那口气压回去,又慢慢吐出来:“我现在再往前一步,就不能只让我一个人算账。” {counterpart}听见这句后没有退,反而把目光压得更直。", + f"{actor_name}看着{counterpart}时终于把最难受的那一点说出来:“这句话不会只停在今晚,后面每一次回头都会被它追上。” {counterpart}没有插话,只把这句留在两人中间。", + f"{actor_name}忽然停住,像是终于想明白碰出真相以后,最难的是{counterpart}还愿不愿意站在同一边。{counterpart}低声道:“你先把真话放下,我再决定要不要跟上。”", + ] + return variants[int(variant_index) % len(variants)] -def _dialogic_opening_suffix(state_before: NarrativeState, beat: SceneBeat) -> str: +def _expansion_dialogue_variation(state_before: NarrativeState, beat: SceneBeat, *, variant_index: int = 0) -> str: actor_name = _actor_name(state_before, beat.event.actors[0]) if beat.event.actors else "那人" - return f"{actor_name}心里先有了一句没出口的话:“真要走到这里,我也不能再装作什么都没发生。”" + counterpart = _actor_name(state_before, beat.event.actors[1]) if len(beat.event.actors) > 1 else "对面那人" + variants = [ + f"{actor_name}低声道:“我不是非要把你拖进来,我只是知道现在再不把这句说出来,后面每一步都会更难走。” {counterpart}没有马上接,只把目光更稳地压了回来。", + f"{counterpart}先问:“你现在才打算认,是因为终于想明白了,还是因为已经退不回去了?” {actor_name}把手指压在案角上,没有立刻躲开这句追问。", + f"{actor_name}说:“我可以自己扛,但我不能再装作你和这件事毫无关系。” {counterpart}听完以后没有退,只让那层沉默更冷了一寸。", + f"{counterpart}把声音压得很轻:“你要真想把话说完,就别只挑对自己有利的那一半。” {actor_name}听见这句时,呼吸明显慢了半拍。", + f"{actor_name}问:“如果我现在把最难听的那句也认下来,你还会站在这里吗?” {counterpart}没有回答,可那一下抬眼已经比任何一句话都更重。", + ] + return variants[int(variant_index) % len(variants)] -def _strong_hook_line(world: WorldBible, scene_plan: ScenePlan, scene_beats: Sequence[SceneBeat]) -> str: - hook = realize_hook(world, scene_plan.ending_hook, scene_beats[-1].event.scene_function).strip() - if any(token in hook for token in ["下一次", "还会", "还没", "追上来", "未说尽"]): - return hook - return f"{hook.rstrip('。')}。下一次开口前,真正追上来的那一句话还没有散。" +def _length_expansion_paragraph( + world: WorldBible, + state_before: NarrativeState, + beat: SceneBeat, + *, + remaining_units: int, + expansion_index: int, +) -> str: + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + variant = (expansion_index + chapter_index) % 7 + if remaining_units >= 420: + if variant == 0: + blocks = [ + _action_pressure_paragraph(world, state_before, beat), + _expansion_dialogue_variation(state_before, beat, variant_index=expansion_index + chapter_index), + _detail_reinforcement_paragraph(world, beat, chapter_index=chapter_index, variant_seed=expansion_index), + ] + elif variant == 1: + blocks = [ + _coverage_gap_bridge_paragraph(world, state_before, beat, variant_seed=expansion_index, chapter_index=chapter_index), + _detail_reinforcement_paragraph(world, beat, chapter_index=chapter_index, variant_seed=expansion_index), + _expansion_dialogue_variation(state_before, beat, variant_index=expansion_index + chapter_index), + ] + elif variant == 2: + blocks = [ + scene_atmosphere(world, beat, chapter_index=chapter_index), + compose_emotion_action(world, state_before, beat, repeated=False), + _action_pressure_paragraph(world, state_before, beat), + _detail_reinforcement_paragraph(world, beat, chapter_index=chapter_index, variant_seed=expansion_index), + ] + elif variant == 3: + blocks = [ + _dialogue_pressure_paragraph(world, state_before, beat), + _expansion_dialogue_variation(state_before, beat, variant_index=expansion_index + chapter_index), + _detail_reinforcement_paragraph(world, beat, chapter_index=chapter_index, variant_seed=expansion_index), + ] + elif variant == 4: + blocks = [ + _coverage_gap_bridge_paragraph(world, state_before, beat, variant_seed=expansion_index, chapter_index=chapter_index), + _action_pressure_paragraph(world, state_before, beat), + _expansion_reflection(state_before, beat, variant_index=expansion_index + chapter_index), + ] + elif variant == 5: + blocks = [ + _sensory_variation_paragraph(world, beat, variant_seed=expansion_index, chapter_index=chapter_index), + _detail_reinforcement_paragraph(world, beat, chapter_index=chapter_index, variant_seed=expansion_index), + _expansion_reflection(state_before, beat, variant_index=expansion_index + chapter_index), + ] + else: + blocks = [ + compose_emotion_action(world, state_before, beat, repeated=False), + _coverage_gap_bridge_paragraph(world, state_before, beat, variant_seed=expansion_index, chapter_index=chapter_index), + _expansion_dialogue_variation(state_before, beat, variant_index=expansion_index + chapter_index), + ] + return " ".join(item for item in blocks if item).strip() + if remaining_units >= 220: + if variant == 0: + return " ".join( + [ + _action_pressure_paragraph(world, state_before, beat), + _expansion_reflection(state_before, beat, variant_index=expansion_index + chapter_index), + ] + ).strip() + if variant == 1: + return " ".join( + [ + _dialogue_pressure_paragraph(world, state_before, beat), + scene_detail(world, beat, repeated=True, chapter_index=chapter_index), + _expansion_reflection(state_before, beat, variant_index=expansion_index + chapter_index), + ] + ).strip() + if variant == 2: + return " ".join( + [ + _coverage_gap_bridge_paragraph(world, state_before, beat, chapter_index=chapter_index), + _expansion_dialogue_variation(state_before, beat, variant_index=expansion_index + chapter_index), + ] + ).strip() + if variant == 3: + return " ".join( + [ + scene_atmosphere(world, beat, chapter_index=chapter_index), + compose_emotion_action(world, state_before, beat, repeated=False), + _detail_reinforcement_paragraph(world, beat, chapter_index=chapter_index, variant_seed=expansion_index), + ] + ).strip() + if variant == 4: + return " ".join( + [ + _sensory_variation_paragraph(world, beat, variant_seed=expansion_index, chapter_index=chapter_index), + _action_pressure_paragraph(world, state_before, beat), + ] + ).strip() + return " ".join( + [ + scene_atmosphere(world, beat, chapter_index=chapter_index), + _expansion_dialogue_variation(state_before, beat, variant_index=expansion_index + chapter_index), + _detail_reinforcement_paragraph(world, beat, chapter_index=chapter_index, variant_seed=expansion_index), + ] + ).strip() + if variant in {0, 2, 4}: + return " ".join( + [ + _sensory_variation_paragraph(world, beat, variant_seed=expansion_index, chapter_index=chapter_index), + _detail_reinforcement_paragraph(world, beat, chapter_index=chapter_index, variant_seed=expansion_index), + ] + ).strip() + return " ".join( + [ + _dialogue_pressure_paragraph(world, state_before, beat), + _sensory_variation_paragraph(world, beat, variant_seed=expansion_index, chapter_index=chapter_index), + ] + ).strip() def repair_chapter_draft( @@ -106,6 +2629,7 @@ def repair_chapter_draft( scene_plan: ScenePlan, scene_beats: Sequence[SceneBeat], draft: ChapterDraft, + render_spec: SceneRenderSpec | None = None, ) -> ChapterDraft: if not scene_beats or not draft.paragraphs: return draft @@ -123,6 +2647,32 @@ def repair_chapter_draft( repaired = _rebuild_draft(paragraphs, metadata) lint_report = lint_chapter_draft(repaired.body) + target_word_count, min_target_word_count, max_target_word_count = _length_target_bounds(draft=draft, render_spec=render_spec) + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + state_metadata = dict(getattr(state_before, "metadata", {}) or {}) + simulation_budget = int(state_metadata.get("authoring_simulation_chapter_budget") or 0) + simulation_quality_mode = str(state_metadata.get("authoring_simulation_quality_mode") or "") + standard_authoring_simulation = 0 < simulation_budget <= 6 and simulation_quality_mode != "benchmark" + if standard_authoring_simulation: + target_word_count = min(target_word_count, 900) + min_target_word_count = min(min_target_word_count, 700) + max_target_word_count = min(max(max_target_word_count, min_target_word_count + 120), 1100) + state_longform_mode = bool( + not standard_authoring_simulation + and ( + getattr(state_before, "current_series_id", None) + or getattr(state_before, "current_volume_id", None) + or getattr(state_before, "current_arc_id", None) + or state_metadata.get("longform_plan_enabled") + or chapter_index >= 20 + ) + ) + if state_longform_mode: + target_word_count = max(target_word_count, 2000) + min_target_word_count = max(min_target_word_count, 1840) + max_target_word_count = max(max_target_word_count, min_target_word_count + 180, target_word_count + 120) + longform_mode = min_target_word_count >= 1800 or state_longform_mode + detail_polish_mode = state_longform_mode and chapter_index >= 20 if float(lint_report.get("repetition_score", 0.0)) > 0.16 and len(scene_beats) >= 2: target_index = min(len(scene_beats), 2) @@ -135,10 +2685,46 @@ def repair_chapter_draft( repaired = _rebuild_draft(paragraphs, metadata) lint_report = lint_chapter_draft(repaired.body) + if float(lint_report.get("repetition_score", 0.0)) > 0.16 and scene_beats: + insert_at = min(len(paragraphs), max(2, len(paragraphs) - 1)) + paragraphs.insert( + insert_at, + _sensory_variation_paragraph( + world, + scene_beats[min(1, len(scene_beats) - 1)], + chapter_index=chapter_index, + ), + ) + remediation_actions.append("q03_sensory_variation") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + repetition_bundle = _coverage_repetition_bundle(paragraphs, scene_beats) if scene_beats else dict(lint_report.get("repetition_signal_bundle") or {}) + if longform_mode and scene_beats and ( + float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) > 0.42 + or float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) > 0.35 + or int(repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + ): + insert_at = min(len(paragraphs), max(2, len(paragraphs) // 2)) + for offset, beat in enumerate(_coverage_gap_target_beats(scene_beats, repetition_bundle)): + paragraphs.insert( + insert_at + offset, + _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=offset, + chapter_index=chapter_index, + ), + ) + remediation_actions.append("q03_coverage_gap_guard") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + if ( float(lint_report.get("exposition_ratio", 0.0)) > 0.44 or repaired.dialogue_count < 2 - or len(repaired.body) < 650 + or story_text_unit_count(repaired.body) < min(650, min_target_word_count) ): insert_at = 2 if len(paragraphs) > 2 else len(paragraphs) paragraphs.insert(insert_at, _dialogue_pressure_paragraph(world, state_before, scene_beats[min(1, len(scene_beats) - 1)])) @@ -146,6 +2732,17 @@ def repair_chapter_draft( repaired = _rebuild_draft(paragraphs, metadata) lint_report = lint_chapter_draft(repaired.body) + if ( + float(lint_report.get("dialogue_plus_action_ratio", 0.0)) < 0.42 + or repaired.action_count < 8 + or draft.action_count < 2 + ): + insert_at = min(len(paragraphs), max(2, len(paragraphs) - 1)) + paragraphs.insert(insert_at, _action_pressure_paragraph(world, state_before, scene_beats[min(1, len(scene_beats) - 1)])) + remediation_actions.append("q05_dialogue_action_balance") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + if ( float(lint_report.get("concrete_detail_density", 0.0)) < DETAIL_DENSITY_FLOOR or repaired.detail_count < 2 @@ -154,16 +2751,36 @@ def repair_chapter_draft( paragraphs[target_index] = " ".join( [ paragraphs[target_index].rstrip(), - _detail_reinforcement_paragraph(world, scene_beats[-1]), + _detail_reinforcement_paragraph(world, scene_beats[-1], chapter_index=chapter_index), ] ).strip() remediation_actions.append("q05_detail_inline") repaired = _rebuild_draft(paragraphs, metadata) lint_report = lint_chapter_draft(repaired.body) - strong_hook = _strong_hook_line(world, scene_plan, scene_beats) + if longform_mode and scene_beats and ( + float(lint_report.get("exposition_ratio", 0.0)) > 0.40 + or float(lint_report.get("dialogue_plus_action_ratio", 0.0)) < 0.46 + or float(lint_report.get("concrete_detail_density", 0.0)) < DETAIL_DENSITY_FLOOR * 1.1 + ): + insert_at = min(len(paragraphs), max(2, len(paragraphs) - 2)) + paragraphs.insert(insert_at, _dialogue_pressure_paragraph(world, state_before, scene_beats[min(1, len(scene_beats) - 1)])) + paragraphs.insert(insert_at + 1, _detail_reinforcement_paragraph(world, scene_beats[-1], chapter_index=chapter_index)) + remediation_actions.append("q04_q05_scene_realization_guard") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + if chapter_index <= 1 and float(lint_report.get("exposition_ratio", 0.0)) > 0.52 and len(scene_beats) >= 2: + insert_at = min(len(paragraphs), max(2, len(paragraphs) - 1)) + paragraphs.insert(insert_at, _dialogue_pressure_paragraph(world, state_before, scene_beats[0])) + paragraphs.insert(insert_at + 1, _action_pressure_paragraph(world, state_before, scene_beats[min(1, len(scene_beats) - 1)])) + remediation_actions.append("q04_reader_entry_pressure_boost") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + strong_hook = _strong_hook_line(world, scene_plan, scene_beats, chapter_index=chapter_index) current_tail = paragraphs[-1] if paragraphs else "" - current_tail_has_hook = any(token in current_tail for token in ["下一次", "还会", "还没", "追上来", "未说尽"]) + current_tail_has_hook = _has_continuation_hook(current_tail) if not current_tail_has_hook: if float(lint_report.get("exposition_ratio", 0.0)) > 0.44 or len(paragraphs) < 3: paragraphs.append(strong_hook) @@ -196,12 +2813,2628 @@ def repair_chapter_draft( elif index == len(paragraphs) - 1: paragraph = strong_hook else: - paragraph = _detail_reinforcement_paragraph(world, scene_beats[min(index - 1, len(scene_beats) - 1)]) + paragraph = _detail_reinforcement_paragraph( + world, + scene_beats[min(index - 1, len(scene_beats) - 1)], + chapter_index=chapter_index, + variant_seed=index, + ) remediation_actions.append(f"q03_post_insert_variation:{index}") deduped.append(paragraph) seen.add(_normalize(paragraph)) repaired = _rebuild_draft(deduped, metadata) + paragraphs = list(repaired.paragraphs) + current_units = story_text_unit_count(repaired.body) + expansion_index = 0 + while current_units < min_target_word_count and scene_beats and expansion_index < 16: + beat = scene_beats[expansion_index % len(scene_beats)] + insert_at = _hook_insert_index(paragraphs) + candidate_paragraph = _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=expansion_index, + ) + seen_paragraphs = {_normalize(item) for item in paragraphs} + retries = 0 + while _normalize(candidate_paragraph) in seen_paragraphs and retries < 6: + retries += 1 + candidate_paragraph = _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=expansion_index + retries * len(scene_beats), + ) + paragraphs.insert(insert_at, candidate_paragraph) + remediation_actions.append(f"length_gate_expand:{expansion_index}") + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + if current_units > max_target_word_count and current_units >= min_target_word_count: + trial_paragraphs = list(paragraphs) + trial_paragraphs.pop(insert_at) + trial_repaired = _rebuild_draft(trial_paragraphs, metadata) + if story_text_unit_count(trial_repaired.body) >= min_target_word_count: + paragraphs = trial_paragraphs + repaired = trial_repaired + current_units = story_text_unit_count(repaired.body) + else: + paragraphs[insert_at] = _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=180, + expansion_index=expansion_index + 100, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + expansion_index += 1 + + longform_exposition_threshold = 0.5 if story_text_unit_count(repaired.body) >= 1800 else 0.44 + if ( + float(lint_report.get("exposition_ratio", 0.0)) > longform_exposition_threshold + or float(lint_report.get("dialogue_plus_action_ratio", 0.0)) < 0.46 + ) and scene_beats: + middle_beat = scene_beats[min(1, len(scene_beats) - 1)] + closing_beat = scene_beats[-1] + insert_at = max(2, len(paragraphs) - 1) + paragraphs.insert(insert_at, _dialogue_pressure_paragraph(world, state_before, middle_beat)) + paragraphs.insert(insert_at + 1, _action_pressure_paragraph(world, state_before, closing_beat)) + remediation_actions.append("q04_longform_shape_guard") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + paragraphs = _replace_redundant_paragraphs_after_expansion( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + if chapter_index <= 1 and float(lint_report.get("exposition_ratio", 0.0)) > 0.52 and paragraphs: + if "“" not in paragraphs[0]: + paragraphs[0] = " ".join( + [ + paragraphs[0].rstrip(), + _dialogic_opening_suffix(state_before, scene_beats[0]), + ] + ).strip() + remediation_actions.append("q04_reader_entry_opening_dialogic") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + if float(lint_report.get("exposition_ratio", 0.0)) > 0.52 and len(scene_beats) >= 2: + insert_at = min(len(paragraphs), max(2, len(paragraphs) - 1)) + paragraphs.insert(insert_at, _dialogue_pressure_paragraph(world, state_before, scene_beats[min(1, len(scene_beats) - 1)])) + remediation_actions.append("q04_reader_entry_retry") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + if ( + float(lint_report.get("dialogue_plus_action_ratio", 0.0)) < 0.42 + or repaired.action_count < 8 + ) and scene_beats: + insert_at = _hook_insert_index(paragraphs) + paragraphs.insert(insert_at, _action_pressure_paragraph(world, state_before, scene_beats[min(1, len(scene_beats) - 1)])) + remediation_actions.append("q05_post_length_action_balance") + repaired = _rebuild_draft(paragraphs, metadata) + + paragraphs = _dedupe_repeated_sentences( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + current_units = story_text_unit_count(repaired.body) + if current_units < min_target_word_count and scene_beats: + paragraphs = list(repaired.paragraphs) + recovery_index = 0 + while current_units < min_target_word_count and recovery_index < 12: + beat = scene_beats[recovery_index % len(scene_beats)] + insert_at = _hook_insert_index(paragraphs) + candidate_paragraph = _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=200 + recovery_index * max(1, len(scene_beats)), + ) + seen_paragraphs = {_normalize(item) for item in paragraphs} + retries = 0 + while _normalize(candidate_paragraph) in seen_paragraphs and retries < 6: + retries += 1 + candidate_paragraph = _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=260 + recovery_index * max(1, len(scene_beats)) + retries, + ) + paragraphs.insert(insert_at, candidate_paragraph) + remediation_actions.append(f"length_gate_recover:{recovery_index}") + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + recovery_index += 1 + paragraphs = _dedupe_repeated_sentences( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + paragraphs = _replace_redundant_paragraphs_after_expansion( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + + current_units = story_text_unit_count(repaired.body) + if chapter_index <= 1 and current_units < 1000 and scene_beats: + paragraphs = list(repaired.paragraphs) + topup_index = 0 + while current_units < 1000 and topup_index < 3: + beat = scene_beats[topup_index % len(scene_beats)] + insert_at = _hook_insert_index(paragraphs) + paragraphs.insert( + insert_at, + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=1000 - current_units, + expansion_index=900 + topup_index * max(1, len(scene_beats)), + ), + ) + remediation_actions.append(f"q04_reader_entry_length_topup:{topup_index}") + repaired = _rebuild_draft(paragraphs, metadata) + paragraphs = _replace_redundant_paragraphs_after_expansion( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + topup_index += 1 + + current_units = story_text_unit_count(repaired.body) + if current_units < min_target_word_count and scene_beats: + paragraphs = list(repaired.paragraphs) + final_topup_index = 0 + while current_units < min_target_word_count and final_topup_index < 4: + beat = scene_beats[(len(paragraphs) + final_topup_index) % len(scene_beats)] + insert_at = _hook_insert_index(paragraphs) + paragraphs.insert( + insert_at, + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=1200 + final_topup_index * max(1, len(scene_beats)), + ), + ) + remediation_actions.append(f"length_gate_final_topup:{final_topup_index}") + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + final_topup_index += 1 + + paragraphs = list(repaired.paragraphs) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(paragraphs, scene_beats) if scene_beats else dict(lint_report.get("repetition_signal_bundle") or {}) + + coverage_needs_bridge = scene_beats and ( + float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) > 0.34 + or float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) > 0.30 + or int(repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + ) + repetition_needs_repair = scene_beats and ( + float(lint_report.get("repetition_score", 0.0) or 0.0) > 0.16 + or float(repetition_bundle.get("overall_repetition_pressure", 0.0) or 0.0) >= 0.42 + ) + + if coverage_needs_bridge: + insert_at = min(_hook_insert_index(paragraphs), max(2, len(paragraphs) // 2)) + for offset, beat in enumerate(_coverage_gap_target_beats(scene_beats, repetition_bundle)): + paragraphs.insert( + insert_at + offset, + _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=500 + offset, + chapter_index=chapter_index, + ), + ) + remediation_actions.append("q03_final_coverage_bridge") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + paragraphs = _replace_redundant_paragraphs_after_expansion( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(paragraphs, scene_beats) + elif repetition_needs_repair: + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + paragraphs = _replace_redundant_paragraphs_after_expansion( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(paragraphs, scene_beats) + + q04_exposition_threshold = 0.5 if story_text_unit_count(repaired.body) >= 1800 else 0.44 + q04_attempt = 0 + while scene_beats and q04_attempt < 3 and ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) > q04_exposition_threshold + or float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < 0.46 + ): + middle_beat = scene_beats[min(q04_attempt % len(scene_beats), len(scene_beats) - 1)] + closing_beat = scene_beats[min((q04_attempt + 1) % len(scene_beats), len(scene_beats) - 1)] + insert_at = _hook_insert_index(paragraphs) + paragraphs.insert( + insert_at, + _dialogue_pressure_paragraph(world, state_before, middle_beat), + ) + paragraphs.insert( + insert_at + 1, + _action_pressure_paragraph(world, state_before, closing_beat), + ) + remediation_actions.append(f"q04_final_dialogue_action_pressure:{q04_attempt}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + paragraphs = _replace_redundant_paragraphs_after_expansion( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + q04_attempt += 1 + + if scene_beats: + paragraphs = _dedupe_repeated_sentences( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + paragraphs = _replace_redundant_paragraphs_after_expansion( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats: + paragraphs = _trim_to_max_units( + repaired.paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + final_exposition_attempt = 0 + while ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) > 0.5 + and final_exposition_attempt < 3 + ): + paragraphs = _dialogize_exposition_paragraphs( + paragraphs, + state_before=state_before, + scene_beats=scene_beats, + attempts=1, + remediation_actions=remediation_actions, + ) + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + final_exposition_attempt += 1 + final_balance_attempt = 0 + while ( + float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < 0.42 + and final_balance_attempt < 2 + ): + insert_at = _hook_insert_index(paragraphs) + paragraphs.insert( + insert_at, + _action_pressure_paragraph( + world, + state_before, + scene_beats[min(final_balance_attempt, len(scene_beats) - 1)], + ), + ) + remediation_actions.append(f"q05_final_action_balance:{final_balance_attempt}") + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + final_balance_attempt += 1 + final_exposition_retry = 0 + while final_exposition_retry < 4: + current_units = story_text_unit_count(repaired.body) + exposition_threshold = 0.5 if current_units >= 1800 else 0.44 + if float(lint_report.get("exposition_ratio", 0.0) or 0.0) <= exposition_threshold: + break + paragraphs = _dialogize_exposition_paragraphs( + repaired.paragraphs, + state_before=state_before, + scene_beats=scene_beats, + attempts=1, + remediation_actions=remediation_actions, + ) + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + final_exposition_retry += 1 + + missing_anchor_beats = _missing_anchor_beats(repaired.paragraphs, scene_beats) if not longform_mode else [] + if missing_anchor_beats: + paragraphs = list(repaired.paragraphs) + insert_at = _hook_insert_index(paragraphs) + for offset, beat in enumerate(missing_anchor_beats[:3]): + paragraphs.insert( + insert_at + offset, + _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=1500 + offset, + chapter_index=chapter_index, + ), + ) + remediation_actions.append("q03_final_missing_anchor_bridge") + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + still_missing_anchor_beats = _missing_anchor_beats(repaired.paragraphs, scene_beats) + if still_missing_anchor_beats: + paragraphs = list(repaired.paragraphs) + for offset, beat in enumerate(still_missing_anchor_beats[:3]): + replacement = _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=1600 + offset, + chapter_index=chapter_index, + ) + candidate_indexes = [ + index + for index, paragraph in enumerate(paragraphs) + if index != len(paragraphs) - 1 + and not _paragraph_contains_event_anchor(paragraph, scene_beats) + ] + if candidate_indexes: + replace_index = max(candidate_indexes, key=lambda index: story_text_unit_count(paragraphs[index])) + paragraphs[replace_index] = replacement + remediation_actions.append(f"q03_final_missing_anchor_replace:{replace_index}") + else: + paragraphs.insert(_hook_insert_index(paragraphs), replacement) + remediation_actions.append("q03_final_missing_anchor_insert") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + final_repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + final_coverage_needs_bridge = ( + float(final_repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) >= 0.5 + or int(final_repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + ) + if final_coverage_needs_bridge: + paragraphs = list(repaired.paragraphs) + used_replace_indexes: set[int] = set() + for offset, beat in enumerate(_expanded_coverage_target_beats(scene_beats, final_repetition_bundle, limit=4)): + bridge = _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=1800 + offset, + chapter_index=chapter_index, + ) + candidate_indexes = [ + index + for index, paragraph in enumerate(paragraphs) + if index not in used_replace_indexes + and 0 < index < len(paragraphs) - 1 + and not _source_anchor_for_beat(paragraph, beat) + ] + if candidate_indexes: + replace_index = max( + candidate_indexes, + key=lambda index: ( + story_text_unit_count(paragraphs[index]), + -_paragraph_anchor_score(paragraphs[index], scene_beats), + ), + ) + paragraphs[replace_index] = bridge + used_replace_indexes.add(replace_index) + remediation_actions.append(f"q03_final_coverage_replace:{replace_index}") + else: + paragraphs.insert(_hook_insert_index(paragraphs), bridge) + remediation_actions.append("q03_final_coverage_insert") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + paragraphs = _drop_repeated_paragraphs_after_trim( + paragraphs, + min_target_word_count=min_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + topup_index = 0 + while current_units < min_target_word_count and topup_index < 4: + beat = scene_beats[topup_index % len(scene_beats)] + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=1800 + topup_index * max(1, len(scene_beats)), + ), + ) + remediation_actions.append(f"q03_final_coverage_length_recover:{topup_index}") + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + topup_index += 1 + paragraphs = _trim_to_max_units( + repaired.paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _drop_repeated_paragraphs_after_trim( + paragraphs, + min_target_word_count=min_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + coverage_retry = 0 + while coverage_retry < 2: + retry_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + retry_needs_bridge = ( + float(retry_bundle.get("event_coverage_gap_score", 0.0) or 0.0) >= 0.5 + or int(retry_bundle.get("uncovered_beat_count", 0) or 0) > 0 + ) + if not retry_needs_bridge: + break + target_beats = _coverage_gap_target_beats(scene_beats, retry_bundle) + if not target_beats: + break + beat = target_beats[0] + paragraphs = list(repaired.paragraphs) + bridge = _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=1900 + coverage_retry, + chapter_index=chapter_index, + ) + candidate_indexes = [ + index + for index, paragraph in enumerate(paragraphs) + if 0 < index < len(paragraphs) - 1 + and not _source_anchor_for_beat(paragraph, beat) + ] + if candidate_indexes: + replace_index = max( + candidate_indexes, + key=lambda index: ( + story_text_unit_count(paragraphs[index]), + -_paragraph_anchor_score(paragraphs[index], scene_beats), + ), + ) + paragraphs[replace_index] = bridge + remediation_actions.append(f"q03_final_coverage_retry_replace:{replace_index}") + else: + paragraphs.insert(_hook_insert_index(paragraphs), bridge) + remediation_actions.append("q03_final_coverage_retry_insert") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + topup_index = 0 + while current_units < min_target_word_count and topup_index < 3: + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + scene_beats[topup_index % len(scene_beats)], + remaining_units=min_target_word_count - current_units, + expansion_index=1950 + coverage_retry * 10 + topup_index, + ), + ) + remediation_actions.append(f"q03_final_coverage_retry_length_recover:{topup_index}") + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + topup_index += 1 + paragraphs = _trim_to_max_units( + repaired.paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + coverage_retry += 1 + + final_exposition_after_coverage = 0 + while final_exposition_after_coverage < 3: + current_units = story_text_unit_count(repaired.body) + exposition_threshold = 0.5 if current_units >= 1800 else 0.44 + if float(lint_report.get("exposition_ratio", 0.0) or 0.0) <= exposition_threshold: + break + paragraphs = _dialogize_exposition_paragraphs( + repaired.paragraphs, + state_before=state_before, + scene_beats=scene_beats, + attempts=1, + remediation_actions=remediation_actions, + ) + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + final_exposition_after_coverage += 1 + + paragraphs = _drop_repeated_paragraphs_after_trim( + repaired.paragraphs, + min_target_word_count=min_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + if list(paragraphs) != list(repaired.paragraphs): + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + final_repetition_retry = 0 + while ( + float(lint_report.get("repetition_score", 0.0) or 0.0) > 0.2 + and final_repetition_retry < 2 + ): + paragraphs = list(repaired.paragraphs) + beat = scene_beats[min(final_repetition_retry, len(scene_beats) - 1)] + paragraphs.insert( + _hook_insert_index(paragraphs), + _lexical_repetition_relief_paragraph( + world, + state_before, + beat, + variant_seed=2100 + final_repetition_retry, + chapter_index=chapter_index, + ), + ) + remediation_actions.append(f"q03_final_lexical_relief:{final_repetition_retry}") + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + final_repetition_retry += 1 + final_detail_retry = 0 + while ( + float(lint_report.get("concrete_detail_density", 0.0) or 0.0) < CONTRACT_DETAIL_DENSITY_FLOOR + and final_detail_retry < 2 + ): + paragraphs = list(repaired.paragraphs) + beat = scene_beats[min(final_detail_retry, len(scene_beats) - 1)] + paragraphs.insert( + _hook_insert_index(paragraphs), + _detail_density_relief_paragraph( + world, + state_before, + beat, + variant_seed=2200 + final_detail_retry, + chapter_index=chapter_index, + ), + ) + remediation_actions.append(f"q05_final_detail_density_relief:{final_detail_retry}") + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + final_detail_retry += 1 + + for _ in range(3): + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + paragraphs = _repair_detail_density_after_trim( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=5, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + paragraphs = _repair_dialogue_action_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + current_units = story_text_unit_count(repaired.body) + final_length_recover = 0 + while current_units < min_target_word_count and final_length_recover < 4: + beat = scene_beats[min(final_length_recover % len(scene_beats), len(scene_beats) - 1)] + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=3600 + final_length_recover, + ), + ) + remediation_actions.append(f"length_gate_post_final_recover:{final_length_recover}") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + current_units = story_text_unit_count(repaired.body) + final_length_recover += 1 + if final_length_recover: + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + current_units = story_text_unit_count(repaired.body) + final_floor_recover = 0 + while current_units < min_target_word_count and final_floor_recover < 3: + beat = scene_beats[(chapter_index + final_floor_recover) % len(scene_beats)] + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=4200 + final_floor_recover, + ), + ) + remediation_actions.append(f"length_gate_final_floor_recover:{final_floor_recover}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + current_units = story_text_unit_count(repaired.body) + final_floor_recover += 1 + + if final_floor_recover: + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + current_units = story_text_unit_count(repaired.body) + final_floor_after_repetition = 0 + while current_units < min_target_word_count and final_floor_after_repetition < 2: + beat = scene_beats[(chapter_index + final_floor_after_repetition + 1) % len(scene_beats)] + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=4300 + final_floor_after_repetition, + ), + ) + remediation_actions.append( + f"length_gate_final_floor_after_repetition:{final_floor_after_repetition}" + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + current_units = story_text_unit_count(repaired.body) + final_floor_after_repetition += 1 + + hard_coverage_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + if ( + float(hard_coverage_bundle.get("event_coverage_gap_score", 0.0) or 0.0) >= 0.42 + or float(hard_coverage_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) >= 0.35 + or int(hard_coverage_bundle.get("uncovered_beat_count", 0) or 0) > 0 + ): + paragraphs = list(repaired.paragraphs) + insert_at = _hook_insert_index(paragraphs) + used_replace_indexes: set[int] = set() + for offset, beat in enumerate(_expanded_coverage_target_beats(scene_beats, hard_coverage_bundle, limit=5)): + bridge = _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=4600 + offset, + chapter_index=chapter_index, + ) + candidate_indexes = [ + index + for index, paragraph in enumerate(paragraphs) + if index not in used_replace_indexes + and 0 < index < len(paragraphs) - 1 + and not _source_anchor_for_beat(paragraph, beat) + ] + if candidate_indexes: + replace_index = min( + candidate_indexes, + key=lambda index: ( + _paragraph_anchor_score(paragraphs[index], scene_beats), + 0 if _is_exposition_paragraph(paragraphs[index]) else 1, + -story_text_unit_count(paragraphs[index]), + ), + ) + paragraphs[replace_index] = bridge + used_replace_indexes.add(replace_index) + remediation_actions.append(f"q03_final_hard_coverage_replace:{replace_index}") + else: + paragraphs.insert(insert_at + offset, bridge) + remediation_actions.append("q03_final_hard_coverage_insert") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + if float(lint_report.get("exposition_ratio", 0.0) or 0.0) > 0.5: + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + post_q04_repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + if _needs_final_repetition_repair(lint_report, post_q04_repetition_bundle): + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append("q03_post_q04_repetition_repair") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + current_units = story_text_unit_count(repaired.body) + post_q04_floor_recover = 0 + while current_units < min_target_word_count and post_q04_floor_recover < 3: + post_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + target_beats = _expanded_coverage_target_beats(scene_beats, post_bundle) + beat = ( + target_beats[min(post_q04_floor_recover % len(target_beats), len(target_beats) - 1)] + if target_beats + else scene_beats[(chapter_index + post_q04_floor_recover) % len(scene_beats)] + ) + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=4700 + post_q04_floor_recover, + ), + ) + remediation_actions.append( + f"length_gate_post_q04_repetition_recover:{post_q04_floor_recover}" + ) + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + current_units = story_text_unit_count(repaired.body) + post_q04_floor_recover += 1 + if float(lint_report.get("exposition_ratio", 0.0) or 0.0) > 0.5: + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + if scene_beats and longform_mode and len(scene_beats) >= 3: + paragraphs = list(repaired.paragraphs) + multi_beat_bridge = _multi_beat_coverage_paragraph( + world, + state_before, + scene_beats, + variant_seed=5000, + chapter_index=chapter_index, + ) + candidate_indexes = [ + index + for index, paragraph in enumerate(paragraphs) + if 0 < index < len(paragraphs) - 1 + and not _has_continuation_hook(paragraph) + ] + if candidate_indexes and multi_beat_bridge: + replace_index = min( + candidate_indexes, + key=lambda index: ( + _paragraph_anchor_score(paragraphs[index], scene_beats), + 0 if _is_exposition_paragraph(paragraphs[index]) else 1, + -story_text_unit_count(paragraphs[index]), + ), + ) + paragraphs[replace_index] = multi_beat_bridge + remediation_actions.append(f"q03_longform_multi_beat_coverage_replace:{replace_index}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + + if scene_beats: + final_sweep_attempt = 0 + while final_sweep_attempt < 3: + paragraphs = list(repaired.paragraphs) + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(paragraphs, scene_beats) + current_units = story_text_unit_count(repaired.body) + exposition_threshold = 0.5 if current_units >= 1800 else 0.44 + needs_q03 = _needs_final_repetition_repair(lint_report, repetition_bundle) + needs_q04 = ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) > exposition_threshold + or float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < 0.42 + ) + if not needs_q03 and not needs_q04: + break + if needs_q03: + paragraphs = _repair_repetition_after_final_detail( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append(f"q03_final_sweep_repair:{final_sweep_attempt}") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + post_repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + if ( + float(post_repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) >= 0.42 + or float(post_repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) >= 0.35 + or int(post_repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + or int(post_repetition_bundle.get("overcovered_beat_count", 0) or 0) >= 2 + ): + paragraphs = list(repaired.paragraphs) + multi_beat_bridge = _multi_beat_coverage_paragraph( + world, + state_before, + scene_beats, + variant_seed=5400 + final_sweep_attempt, + chapter_index=chapter_index, + ) + candidate_indexes = [ + index + for index, paragraph in enumerate(paragraphs) + if 0 < index < len(paragraphs) - 1 + and not _has_continuation_hook(paragraph) + ] + if candidate_indexes and multi_beat_bridge: + replace_index = min( + candidate_indexes, + key=lambda index: ( + _paragraph_anchor_score(paragraphs[index], scene_beats), + 0 if _is_exposition_paragraph(paragraphs[index]) else 1, + -story_text_unit_count(paragraphs[index]), + ), + ) + paragraphs[replace_index] = multi_beat_bridge + remediation_actions.append(f"q03_final_sweep_multi_beat_replace:{replace_index}") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + if needs_q04: + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + paragraphs = _repair_dialogue_action_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append(f"q04_final_sweep_repair:{final_sweep_attempt}") + repaired = _rebuild_draft(paragraphs, metadata) + paragraphs = _dedupe_repeated_sentences( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + if current_units < min_target_word_count: + beat = scene_beats[(chapter_index + final_sweep_attempt) % len(scene_beats)] + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=5200 + final_sweep_attempt, + ), + ) + remediation_actions.append(f"length_gate_final_sweep_recover:{final_sweep_attempt}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + final_sweep_attempt += 1 + + if scene_beats: + current_units = story_text_unit_count(repaired.body) + final_contract_floor_recover = 0 + while current_units < min_target_word_count and final_contract_floor_recover < 4: + repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + target_beats = _expanded_coverage_target_beats(scene_beats, repetition_bundle) + beat = ( + target_beats[min(final_contract_floor_recover % len(target_beats), len(target_beats) - 1)] + if target_beats + else scene_beats[(chapter_index + final_contract_floor_recover) % len(scene_beats)] + ) + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=6200 + final_contract_floor_recover, + ), + ) + remediation_actions.append(f"length_gate_final_contract_floor:{final_contract_floor_recover}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + final_contract_floor_recover += 1 + if final_contract_floor_recover: + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + if ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) > (0.5 if story_text_unit_count(repaired.body) >= 1800 else 0.44) + or float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < 0.42 + ): + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats: + lint_report = lint_chapter_draft(repaired.body) + detail_density_target = ( + LONGFORM_DETAIL_DENSITY_POLISH_TARGET + if detail_polish_mode + else CONTRACT_DETAIL_DENSITY_FLOOR + ) + min_detail_count_target = ( + max(12, int(story_text_unit_count(repaired.body) * LONGFORM_DETAIL_DENSITY_POLISH_FLOOR)) + if detail_polish_mode + else 12 + ) + if float(lint_report.get("concrete_detail_density", 0.0) or 0.0) < detail_density_target: + paragraphs = _repair_detail_density_after_trim( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + target_density=detail_density_target, + min_detail_count=min_detail_count_target, + max_attempts=7 if detail_polish_mode else 4, + fast_scene_detail_only=detail_polish_mode, + ) + remediation_actions.append( + "q05_final_longform_detail_density_polish" + if detail_polish_mode + else "q05_final_contract_detail_density_repair" + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + if _needs_final_repetition_repair(lint_report, repetition_bundle): + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append("q03_post_final_detail_density_repair") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + if ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) > (0.5 if story_text_unit_count(repaired.body) >= 1800 else 0.44) + or float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < 0.42 + ): + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + paragraphs = _repair_dialogue_action_after_final_detail( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append("q04_post_final_detail_density_repair") + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + final_detail_length_recover = 0 + while current_units < min_target_word_count and final_detail_length_recover < 3: + beat = scene_beats[(chapter_index + final_detail_length_recover) % len(scene_beats)] + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=6600 + final_detail_length_recover, + ), + ) + remediation_actions.append(f"length_gate_post_final_detail_density:{final_detail_length_recover}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + final_detail_length_recover += 1 + + if scene_beats: + current_units = story_text_unit_count(repaired.body) + final_unconditional_floor = 0 + while current_units < min_target_word_count and final_unconditional_floor < 4: + repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + target_beats = _expanded_coverage_target_beats(scene_beats, repetition_bundle) + beat = ( + target_beats[min(final_unconditional_floor % len(target_beats), len(target_beats) - 1)] + if target_beats + else scene_beats[(chapter_index + final_unconditional_floor) % len(scene_beats)] + ) + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=7000 + final_unconditional_floor, + ), + ) + remediation_actions.append(f"length_gate_final_unconditional_floor:{final_unconditional_floor}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + final_unconditional_floor += 1 + lint_report = lint_chapter_draft(repaired.body) + if ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) > (0.5 if story_text_unit_count(repaired.body) >= 1800 else 0.44) + or float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < 0.42 + ): + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + paragraphs = _repair_dialogue_action_after_final_detail( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append("q04_post_final_unconditional_floor_repair") + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + final_q04_floor_recover = 0 + while current_units < min_target_word_count and final_q04_floor_recover < 3: + beat = scene_beats[(chapter_index + final_q04_floor_recover + 1) % len(scene_beats)] + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=7200 + final_q04_floor_recover, + ), + ) + remediation_actions.append(f"length_gate_post_final_q04:{final_q04_floor_recover}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + final_q04_floor_recover += 1 + + if scene_beats and detail_polish_mode: + lint_report = lint_chapter_draft(repaired.body) + if float(lint_report.get("concrete_detail_density", 0.0) or 0.0) < LONGFORM_DETAIL_DENSITY_POLISH_FLOOR: + paragraphs = _repair_detail_density_after_trim( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + target_density=LONGFORM_DETAIL_DENSITY_POLISH_TARGET, + min_detail_count=max(12, int(story_text_unit_count(repaired.body) * LONGFORM_DETAIL_DENSITY_POLISH_FLOOR)), + max_attempts=5, + fast_scene_detail_only=True, + ) + remediation_actions.append("q05_final_longform_detail_density_floor") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + if _needs_final_repetition_repair(lint_report, repetition_bundle): + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append("q03_post_longform_detail_density_floor") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + if ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) > (0.5 if story_text_unit_count(repaired.body) >= 1800 else 0.44) + or float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < 0.42 + ): + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + paragraphs = _repair_dialogue_action_after_final_detail( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append("q04_post_longform_detail_density_floor") + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats and not longform_mode: + missing_anchor_beats = _missing_anchor_beats(repaired.paragraphs, scene_beats) + if missing_anchor_beats: + paragraphs = list(repaired.paragraphs) + for offset, beat in enumerate(missing_anchor_beats[:3]): + bridge = _coverage_gap_bridge_paragraph( + world, + state_before, + beat, + variant_seed=7800 + offset, + chapter_index=chapter_index, + ) + over_budget = story_text_unit_count("\n\n".join(paragraphs + [bridge])) > max_target_word_count + candidate_indexes = [ + index + for index, paragraph in enumerate(paragraphs) + if index != len(paragraphs) - 1 + and not _paragraph_contains_event_anchor(paragraph, scene_beats) + ] + if over_budget and candidate_indexes: + replace_index = max( + candidate_indexes, + key=lambda index: ( + 1 if _is_exposition_paragraph(paragraphs[index]) else 0, + story_text_unit_count(paragraphs[index]), + ), + ) + paragraphs[replace_index] = bridge + remediation_actions.append(f"q03_final_anchor_restore_replace:{replace_index}") + else: + paragraphs.insert(_hook_insert_index(paragraphs), bridge) + remediation_actions.append("q03_final_anchor_restore_insert") + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats: + paragraphs = list(repaired.paragraphs) + tail = paragraphs[-1] if paragraphs else "" + if not _has_continuation_hook(tail): + if paragraphs: + paragraphs[-1] = strong_hook + else: + paragraphs.append(strong_hook) + remediation_actions.append("q09_final_hook_restore") + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats: + current_units = story_text_unit_count(repaired.body) + final_post_polish_length_recover = 0 + while current_units < min_target_word_count and final_post_polish_length_recover < 4: + repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + target_beats = _expanded_coverage_target_beats(scene_beats, repetition_bundle) + beat = ( + target_beats[min(final_post_polish_length_recover % len(target_beats), len(target_beats) - 1)] + if target_beats + else scene_beats[(chapter_index + final_post_polish_length_recover) % len(scene_beats)] + ) + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=7600 + final_post_polish_length_recover, + ), + ) + remediation_actions.append(f"length_gate_post_detail_polish:{final_post_polish_length_recover}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + final_post_polish_length_recover += 1 + + if scene_beats and story_text_unit_count(repaired.body) > max_target_word_count: + paragraphs = _trim_to_max_units( + repaired.paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats and detail_polish_mode: + for final_detail_topup in range(2): + lint_report = lint_chapter_draft(repaired.body) + if float(lint_report.get("concrete_detail_density", 0.0) or 0.0) >= 0.07: + break + paragraphs = _repair_detail_density_after_trim( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + target_density=LONGFORM_DETAIL_DENSITY_POLISH_TARGET, + min_detail_count=max(12, int(story_text_unit_count(repaired.body) * LONGFORM_DETAIL_DENSITY_POLISH_FLOOR)), + max_attempts=4, + fast_scene_detail_only=True, + ) + remediation_actions.append(f"q05_final_longform_detail_density_topup:{final_detail_topup}") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + if _needs_final_repetition_repair(lint_report, repetition_bundle): + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append(f"q03_post_longform_detail_density_topup:{final_detail_topup}") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + if ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) > (0.5 if story_text_unit_count(repaired.body) >= 1800 else 0.44) + or float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < 0.42 + ): + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + paragraphs = _repair_dialogue_action_after_final_detail( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append(f"q04_post_longform_detail_density_topup:{final_detail_topup}") + repaired = _rebuild_draft(paragraphs, metadata) + if story_text_unit_count(repaired.body) > max_target_word_count: + paragraphs = _trim_to_max_units( + repaired.paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats: + final_length_floor = 0 + current_units = story_text_unit_count(repaired.body) + while current_units < min_target_word_count and final_length_floor < 4: + beat = scene_beats[(chapter_index + final_length_floor) % len(scene_beats)] + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=8200 + final_length_floor, + ), + ) + remediation_actions.append(f"length_gate_final_post_topup_floor:{final_length_floor}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + if story_text_unit_count("\n\n".join(paragraphs)) > max_target_word_count: + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + final_length_floor += 1 + paragraphs = list(repaired.paragraphs) + if paragraphs and not _has_continuation_hook(paragraphs[-1]): + paragraphs[-1] = strong_hook + remediation_actions.append("q09_final_post_topup_hook_restore") + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats: + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + if ( + float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) >= 0.42 + or float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) >= 0.35 + or int(repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + ): + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append("q03_final_post_topup_coverage_repair") + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + if ( + float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) >= 0.42 + or float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) >= 0.35 + or int(repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + ): + paragraphs = list(repaired.paragraphs) + for offset, beat in enumerate(_coverage_gap_target_beats(scene_beats, repetition_bundle)[:2]): + paragraphs.insert( + _hook_insert_index(paragraphs), + _coverage_anchor_echo_paragraph( + world, + state_before, + beat, + variant_seed=8600 + offset, + chapter_index=chapter_index, + ), + ) + remediation_actions.append(f"q03_final_anchor_echo_insert:{offset}") + if story_text_unit_count("\n\n".join(paragraphs)) > max_target_word_count: + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + if ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) > (0.5 if story_text_unit_count(repaired.body) >= 1800 else 0.44) + or float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < 0.42 + ): + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + paragraphs = _repair_dialogue_action_after_final_detail( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + remediation_actions.append("q04_final_post_topup_repair") + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + post_q04_length_floor = 0 + while current_units < min_target_word_count and post_q04_length_floor < 3: + beat = scene_beats[(chapter_index + post_q04_length_floor + 1) % len(scene_beats)] + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=8400 + post_q04_length_floor, + ), + ) + remediation_actions.append(f"length_gate_post_final_q04_guard:{post_q04_length_floor}") + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + post_q04_length_floor += 1 + paragraphs = list(repaired.paragraphs) + if paragraphs and not _has_continuation_hook(paragraphs[-1]): + paragraphs[-1] = strong_hook + remediation_actions.append("q09_post_final_q04_hook_restore") + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats and longform_mode: + lint_report = lint_chapter_draft(repaired.body) + if float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < LONGFORM_STOP_READY_DIALOGUE_TARGET: + paragraphs = _repair_longform_stop_ready_dialogue_guard( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=5, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + length_recover = 0 + while current_units < min_target_word_count and length_recover < 2: + beat = scene_beats[(chapter_index + length_recover) % len(scene_beats)] + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=9000 + length_recover, + ), + ) + remediation_actions.append(f"length_gate_post_stop_ready_dialogue:{length_recover}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + length_recover += 1 + paragraphs = list(repaired.paragraphs) + if paragraphs and not _has_continuation_hook(paragraphs[-1]): + paragraphs[-1] = strong_hook + remediation_actions.append("q09_post_stop_ready_dialogue_hook_restore") + repaired = _rebuild_draft(paragraphs, metadata) + if detail_polish_mode: + lint_report = lint_chapter_draft(repaired.body) + if float(lint_report.get("concrete_detail_density", 0.0) or 0.0) < 0.07: + paragraphs = _repair_detail_density_after_trim( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + target_density=LONGFORM_DETAIL_DENSITY_POLISH_TARGET, + min_detail_count=max( + 12, + int(story_text_unit_count(repaired.body) * LONGFORM_DETAIL_DENSITY_POLISH_FLOOR), + ), + max_attempts=4, + fast_scene_detail_only=False, + ) + remediation_actions.append("q05_post_stop_ready_dialogue_detail_restore") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + lint_report = lint_chapter_draft(repaired.body) + if ( + float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) + < LONGFORM_STOP_READY_DIALOGUE_TARGET + ): + paragraphs = _repair_longform_stop_ready_dialogue_guard( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=2, + ) + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats and longform_mode: + for balance_attempt in range(2): + changed = False + lint_report = lint_chapter_draft(repaired.body) + repetition_bundle = dict(lint_report.get("repetition_signal_bundle") or {}) + if ( + _needs_final_repetition_repair(lint_report, repetition_bundle) + or float(lint_report.get("repetition_score", 0.0) or 0.0) > 0.18 + ): + paragraphs = _repair_repetition_after_final_detail( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=2, + ) + remediation_actions.append(f"q03_final_stop_ready_balance:{balance_attempt}") + repaired = _rebuild_draft(paragraphs, metadata) + changed = True + + lint_report = lint_chapter_draft(repaired.body) + if float(lint_report.get("exposition_ratio", 0.0) or 0.0) > 0.49: + paragraphs = _repair_final_q04_micro( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + paragraphs = _repair_dialogue_action_after_final_detail( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + ) + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + remediation_actions.append(f"q04_final_stop_ready_balance:{balance_attempt}") + repaired = _rebuild_draft(paragraphs, metadata) + changed = True + + lint_report = lint_chapter_draft(repaired.body) + if detail_polish_mode and float(lint_report.get("concrete_detail_density", 0.0) or 0.0) < 0.07: + paragraphs = _repair_detail_density_after_trim( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + target_density=LONGFORM_DETAIL_DENSITY_POLISH_TARGET, + min_detail_count=max( + 12, + int(story_text_unit_count(repaired.body) * LONGFORM_DETAIL_DENSITY_POLISH_FLOOR), + ), + max_attempts=5, + fast_scene_detail_only=False, + ) + remediation_actions.append(f"q05_final_stop_ready_balance:{balance_attempt}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + changed = True + + lint_report = lint_chapter_draft(repaired.body) + if float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < LONGFORM_STOP_READY_DIALOGUE_TARGET: + paragraphs = _repair_longform_stop_ready_dialogue_guard( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=3, + ) + repaired = _rebuild_draft(paragraphs, metadata) + changed = True + + if not changed: + break + + lint_report = lint_chapter_draft(repaired.body) + if ( + detail_polish_mode + and float(lint_report.get("concrete_detail_density", 0.0) or 0.0) < 0.065 + and float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) + >= LONGFORM_STOP_READY_DIALOGUE_TARGET + 0.03 + ): + paragraphs = _repair_detail_density_after_trim( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + target_density=LONGFORM_DETAIL_DENSITY_POLISH_TARGET, + min_detail_count=max( + 12, + int(story_text_unit_count(repaired.body) * LONGFORM_DETAIL_DENSITY_POLISH_FLOOR), + ), + max_attempts=3, + fast_scene_detail_only=False, + ) + remediation_actions.append("q05_final_stop_ready_buffered_topup") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + if story_text_unit_count("\n\n".join(paragraphs)) > max_target_word_count: + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + + repetition_bundle = _coverage_repetition_bundle(repaired.paragraphs, scene_beats) + if ( + float(repetition_bundle.get("event_coverage_gap_score", 0.0) or 0.0) > 0.42 + or float(repetition_bundle.get("beat_coverage_gap_score", 0.0) or 0.0) > 0.35 + or int(repetition_bundle.get("uncovered_beat_count", 0) or 0) > 0 + ): + paragraphs = list(repaired.paragraphs) + for offset, beat in enumerate(_coverage_gap_target_beats(scene_beats, repetition_bundle)[:2]): + bridge = _coverage_anchor_echo_paragraph( + world, + state_before, + beat, + variant_seed=9400 + offset, + chapter_index=chapter_index, + ) + replace_index = _final_repetition_replace_index(paragraphs, repetition_bundle, scene_beats) + if replace_index is None: + paragraphs.insert(_hook_insert_index(paragraphs), bridge) + remediation_actions.append(f"q03_final_stop_ready_anchor_insert:{offset}") + else: + paragraphs[replace_index] = bridge + remediation_actions.append(f"q03_final_stop_ready_anchor_replace:{replace_index}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + if story_text_unit_count("\n\n".join(paragraphs)) > max_target_word_count: + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + repaired = _rebuild_draft(paragraphs, metadata) + + current_units = story_text_unit_count(repaired.body) + length_recover = 0 + while current_units < min_target_word_count and length_recover < 2: + beat = scene_beats[(chapter_index + length_recover) % len(scene_beats)] + paragraphs = list(repaired.paragraphs) + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - current_units, + expansion_index=9300 + length_recover, + ), + ) + remediation_actions.append(f"length_gate_final_stop_ready_balance:{length_recover}") + paragraphs = _dedupe_repeated_sentences( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + ) + repaired = _rebuild_draft(paragraphs, metadata) + current_units = story_text_unit_count(repaired.body) + length_recover += 1 + paragraphs = list(repaired.paragraphs) + if paragraphs and not _has_continuation_hook(paragraphs[-1]): + paragraphs[-1] = strong_hook + remediation_actions.append("q09_final_stop_ready_balance_hook_restore") + repaired = _rebuild_draft(paragraphs, metadata) + + paragraphs = _repair_longform_surface_issue_mix_guard( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=5, + ) + repaired = _rebuild_draft(paragraphs, metadata) + if story_text_unit_count(repaired.body) < min_target_word_count: + paragraphs = list(repaired.paragraphs) + recover_attempt = 0 + while story_text_unit_count("\n\n".join(paragraphs)) < min_target_word_count and recover_attempt < 2: + beat = scene_beats[(chapter_index + recover_attempt) % len(scene_beats)] + paragraphs.insert( + _hook_insert_index(paragraphs), + compose_late_longform_compact_exchange( + world, + state_before, + beat, + repeated=True, + variant_offset=9900 + recover_attempt, + ), + ) + remediation_actions.append(f"length_gate_longform_surface_guard:{recover_attempt}") + recover_attempt += 1 + paragraphs = _repair_longform_surface_issue_mix_guard( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=2, + ) + repaired = _rebuild_draft(paragraphs, metadata) + + lint_report = lint_chapter_draft(repaired.body) + if detail_polish_mode and float(lint_report.get("concrete_detail_density", 0.0) or 0.0) < 0.065: + paragraphs = _repair_detail_density_after_trim( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + target_density=LONGFORM_DETAIL_DENSITY_POLISH_TARGET, + min_detail_count=max( + 12, + int(story_text_unit_count(repaired.body) * LONGFORM_DETAIL_DENSITY_POLISH_FLOOR), + ), + max_attempts=4, + fast_scene_detail_only=False, + ) + remediation_actions.append("q05_after_longform_surface_guard") + paragraphs = _dialogize_longform_exposition_surface( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + max_attempts=4, + ) + paragraphs = _scrub_longform_suspicious_refrains( + paragraphs, + chapter_index=chapter_index, + remediation_actions=remediation_actions, + ) + if story_text_unit_count("\n\n".join(paragraphs)) > max_target_word_count: + paragraphs = _trim_to_max_units( + paragraphs, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + ) + paragraphs = _repair_longform_surface_issue_mix_guard( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=3, + ) + paragraphs = _inline_longform_detail_surface_topup( + paragraphs, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + target_density=0.065, + max_attempts=4, + ) + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats and longform_mode: + paragraphs = list(repaired.paragraphs) + length_recover = 0 + while story_text_unit_count("\n\n".join(paragraphs)) < min_target_word_count and length_recover < 3: + beat = scene_beats[(chapter_index + length_recover) % len(scene_beats)] + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - story_text_unit_count("\n\n".join(paragraphs)), + expansion_index=9950 + length_recover, + ), + ) + remediation_actions.append(f"length_gate_final_longform_surface_floor:{length_recover}") + length_recover += 1 + if length_recover: + paragraphs = _repair_longform_surface_issue_mix_guard( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=2, + ) + paragraphs = _inline_longform_detail_surface_topup( + paragraphs, + state_before=state_before, + scene_beats=scene_beats, + remediation_actions=remediation_actions, + target_density=0.065, + max_attempts=3, + ) + final_recover = 0 + while story_text_unit_count("\n\n".join(paragraphs)) < min_target_word_count and final_recover < 6: + beat = scene_beats[(chapter_index + final_recover + 7) % len(scene_beats)] + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - story_text_unit_count("\n\n".join(paragraphs)), + expansion_index=9980 + final_recover, + ), + ) + remediation_actions.append(f"length_gate_final_longform_surface_refloor:{final_recover}") + final_recover += 1 + if paragraphs and not _has_continuation_hook(paragraphs[-1]): + paragraphs[-1] = strong_hook + remediation_actions.append("q09_final_longform_surface_floor_hook_restore") + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats and longform_mode: + paragraphs = list(repaired.paragraphs) + lint_report = lint_chapter_draft("\n\n".join(paragraphs)) + repetition_bundle = _coverage_repetition_bundle(paragraphs, scene_beats) + if ( + story_text_unit_count("\n\n".join(paragraphs)) < min_target_word_count + or _longform_surface_q03_needs_repair(lint_report, repetition_bundle) + ): + paragraphs = _repair_longform_surface_issue_mix_guard( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=4, + ) + final_guard_recover = 0 + while story_text_unit_count("\n\n".join(paragraphs)) < min_target_word_count and final_guard_recover < 6: + beat = scene_beats[(chapter_index + final_guard_recover + 13) % len(scene_beats)] + paragraphs.insert( + _hook_insert_index(paragraphs), + _length_expansion_paragraph( + world, + state_before, + beat, + remaining_units=min_target_word_count - story_text_unit_count("\n\n".join(paragraphs)), + expansion_index=10040 + final_guard_recover, + ), + ) + remediation_actions.append(f"length_gate_final_longform_contract_refloor:{final_guard_recover}") + final_guard_recover += 1 + paragraphs = _scrub_longform_suspicious_refrains( + paragraphs, + chapter_index=chapter_index, + remediation_actions=remediation_actions, + ) + if paragraphs and not _has_continuation_hook(paragraphs[-1]): + paragraphs[-1] = strong_hook + remediation_actions.append("q09_final_longform_contract_hook_restore") + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats and longform_mode and chapter_index >= 20: + lint_report = lint_chapter_draft(repaired.body) + if float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < 0.54: + paragraphs = list(repaired.paragraphs) + final_dialogue_inline = 0 + while final_dialogue_inline < 8: + lint_report = lint_chapter_draft("\n\n".join(paragraphs)) + if float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) >= LONGFORM_STOP_READY_DIALOGUE_TARGET: + break + candidates = [ + index + for index, paragraph in enumerate(paragraphs) + if index < len(paragraphs) - 1 and not _has_continuation_hook(paragraph) + ] + if not candidates: + candidates = list(range(0, max(0, len(paragraphs) - 1))) + if not candidates: + break + target_index = max( + candidates, + key=lambda index: ( + 1 if _is_exposition_paragraph(paragraphs[index]) else 0, + -_paragraph_action_count(paragraphs[index]), + _paragraph_anchor_score(paragraphs[index], scene_beats), + -story_text_unit_count(paragraphs[index]), + ), + ) + beat = _beat_for_paragraph(paragraphs[target_index], scene_beats, fallback_index=target_index + final_dialogue_inline) + paragraphs[target_index] = " ".join( + [ + paragraphs[target_index].rstrip(), + _compact_action_dialogue_sentence( + world, + state_before, + beat, + variant_seed=10120 + final_dialogue_inline + target_index * 19, + ), + ] + ).strip() + remediation_actions.append(f"q04_final_dialogue_inline:{target_index}") + final_dialogue_inline += 1 + final_dialogue_recover = 0 + while story_text_unit_count("\n\n".join(paragraphs)) < min_target_word_count and final_dialogue_recover < 4: + beat = scene_beats[(chapter_index + final_dialogue_recover + 17) % len(scene_beats)] + paragraphs.insert( + _hook_insert_index(paragraphs), + compose_late_longform_compact_exchange( + world, + state_before, + beat, + repeated=True, + variant_offset=10120 + final_dialogue_recover, + ), + ) + remediation_actions.append(f"q04_final_dialogue_refloor:{final_dialogue_recover}") + final_dialogue_recover += 1 + paragraphs = _scrub_longform_suspicious_refrains( + paragraphs, + chapter_index=chapter_index, + remediation_actions=remediation_actions, + ) + if paragraphs and not _has_continuation_hook(paragraphs[-1]): + paragraphs[-1] = strong_hook + remediation_actions.append("q09_final_dialogue_hook_restore") + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats and longform_mode and chapter_index >= 20: + paragraphs = _final_longform_q03_closeout( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=4, + ) + final_refloor = 0 + while story_text_unit_count("\n\n".join(paragraphs)) < min_target_word_count and final_refloor < 3: + beat = scene_beats[(chapter_index + final_refloor + 23) % len(scene_beats)] + paragraphs.insert( + _hook_insert_index(paragraphs), + _lexical_repetition_relief_paragraph( + world, + state_before, + beat, + variant_seed=11320 + final_refloor, + chapter_index=chapter_index, + ), + ) + remediation_actions.append(f"length_gate_final_longform_q03_closeout:{final_refloor}") + paragraphs = _final_longform_q03_closeout( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=2, + ) + final_refloor += 1 + paragraphs = _scrub_longform_suspicious_refrains( + paragraphs, + chapter_index=chapter_index, + remediation_actions=remediation_actions, + ) + if paragraphs and not _has_continuation_hook(paragraphs[-1]): + paragraphs[-1] = strong_hook + remediation_actions.append("q09_final_longform_q03_closeout_hook_restore") + repaired = _rebuild_draft(paragraphs, metadata) + + if scene_beats and longform_mode and chapter_index >= 20: + lint_report = lint_chapter_draft(repaired.body) + if ( + float(lint_report.get("exposition_ratio", 0.0) or 0.0) > LONGFORM_STOP_READY_EXPOSITION_TARGET + or float(lint_report.get("dialogue_plus_action_ratio", 0.0) or 0.0) < LONGFORM_STOP_READY_DIALOGUE_TARGET + ): + paragraphs = _final_longform_q04_closeout( + repaired.paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=5, + ) + paragraphs = _final_longform_q03_closeout( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=2, + ) + if paragraphs and not _has_continuation_hook(paragraphs[-1]): + paragraphs[-1] = strong_hook + remediation_actions.append("q09_final_longform_q04_closeout_hook_restore") + repaired = _rebuild_draft(paragraphs, metadata) + + cleaned_body, broken_slot_report = clean_broken_reader_slots(repaired.body) + if broken_slot_report.get("broken_slot_repaired") or cleaned_body != repaired.body.strip(): + repaired = _rebuild_draft( + [paragraph for paragraph in cleaned_body.split("\n\n") if paragraph.strip()], + metadata, + ) + remediation_actions.append("broken_slot_final_sanitize") + + if scene_beats and longform_mode and chapter_index >= 20: + paragraphs = list(repaired.paragraphs) + final_floor_attempt = 0 + while final_floor_attempt < 3: + lint_report = lint_chapter_draft("\n\n".join(paragraphs)) + if ( + story_text_unit_count("\n\n".join(paragraphs)) >= min_target_word_count + and float(lint_report.get("concrete_detail_density", 0.0) or 0.0) >= CONTRACT_DETAIL_DENSITY_FLOOR + ): + break + beat = scene_beats[(chapter_index + final_floor_attempt + 31) % len(scene_beats)] + paragraphs.insert( + _hook_insert_index(paragraphs), + _dialogue_scene_replacement_paragraph( + world, + state_before, + beat, + variant_seed=11600 + final_floor_attempt, + chapter_index=chapter_index, + ), + ) + remediation_actions.append(f"length_detail_final_longform_floor:{final_floor_attempt}") + paragraphs = _final_longform_q04_closeout( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=2, + ) + paragraphs = _final_longform_q03_closeout( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=2, + ) + final_floor_attempt += 1 + if paragraphs and not _has_continuation_hook(paragraphs[-1]): + paragraphs[-1] = strong_hook + remediation_actions.append("q09_final_floor_hook_restore") + paragraphs = _final_longform_surface_reconcile( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=3, + ) + paragraphs = _force_longform_q04_paragraph_mix( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + remediation_actions=remediation_actions, + max_attempts=3, + ) + paragraphs = _final_longform_q03_closeout( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, + remediation_actions=remediation_actions, + max_attempts=3, + ) + paragraphs = _force_longform_q04_paragraph_mix( + paragraphs, + world=world, + state_before=state_before, + scene_beats=scene_beats, + min_target_word_count=min_target_word_count, + remediation_actions=remediation_actions, + max_attempts=2, + ) + if paragraphs and not _has_continuation_hook(paragraphs[-1]): + paragraphs[-1] = strong_hook + remediation_actions.append("q09_final_surface_reconcile_hook_restore") + repaired = _rebuild_draft(paragraphs, metadata) + + repaired.metadata["target_word_count"] = target_word_count + repaired.metadata["min_target_word_count"] = min_target_word_count + repaired.metadata["max_target_word_count"] = max_target_word_count + repaired.metadata["text_unit_count"] = story_text_unit_count(repaired.body) repaired.metadata["quality_pass_actions"] = remediation_actions repaired.metadata["quality_pass_applied"] = bool(remediation_actions) return repaired diff --git a/src/narrativeos/core/scene_realizer.py b/src/narrativeos/core/scene_realizer.py index 92a0bd9..12aea06 100644 --- a/src/narrativeos/core/scene_realizer.py +++ b/src/narrativeos/core/scene_realizer.py @@ -1,32 +1,207 @@ from __future__ import annotations +import re + from ..models import NarrativeState, SceneBeat, WorldBible from .contracts import style_pack_from_world from .dialogue import compose_dialogue from .emotion_actions import compose_emotion_action from .sensory_grounding import scene_atmosphere, scene_detail +SCENE_FUNCTION_LABELS = { + "false_peace": "表面平静", + "temptation": "试探", + "truth_trial": "真相逼近", + "mask_crack": "裂口", + "confession_window": "真话窗口", + "debt_exchange": "旧账回潮", + "karma_ripening": "因果回响", + "humiliation": "难堪代价", + "vow_payment": "誓言偿付", + "misrecognition": "误解升级", + "mercy_vs_control": "庇护与控制", +} + + +def _variant_index(beat: SceneBeat, *, chapter_index: int = 0, modulo: int) -> int: + if modulo <= 0: + return 0 + event_id = str(getattr(beat.event, "event_id", "") or "") + scene_function = str(getattr(beat.event, "scene_function", "") or "") + dramatic_job = str(getattr(beat, "dramatic_job", "") or "") + event_seed = sum(ord(char) for char in f"{event_id}:{scene_function}:{dramatic_job}") + chapter_seed = max(0, int(chapter_index)) + beat_seed = max(0, int(getattr(beat, "beat_index", 0))) + chapter_band_seed = max(0, chapter_seed // 40) + return (event_seed + chapter_seed * 7 + chapter_band_seed * 23 + beat_seed * 13) % modulo + + +def _opening_detail(beat: SceneBeat, *, chapter_index: int = 0) -> str: + location = beat.event.location or "眼前这一处" + scene_function = SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + variants = [ + f"{location}边的门、窗、灯影和衣袖摩擦出的细响并没有停,连案角那页纸和茶气都把这一步{scene_function}的分量压得更清。", + f"风从{location}外掠过檐角,带得阶前、窗边和衣角都起了一层轻微的动静,连灯下那点影子都把场面拢得更紧。", + f"{location}里的光、影、纸页和回声并没有安静下来,反而一层层把这句还没说透的话托到了更近的地方。", + f"{location}里先露出来的是器物边缘那点冷光,随后才是袖口、鞋底和门缝里细碎的响动,把{scene_function}压出新的方向。", + f"{location}的空气被一声轻响分开,桌沿、窗纸和灯芯各自晃了一下,让这一步{scene_function}不再只靠一句话撑着。", + f"{location}旁的阴影退了半寸,杯沿、水痕和衣摆上的尘色反倒更清,像在替场面换一层更具体的证词。", + f"最先变重的不是人声,而是{location}里那点冷风、旧木味和纸页摩擦声,把{scene_function}从心里推回了眼前。", + f"{location}边有人轻轻挪了一步,鞋底擦过地面的声响拖得很短,却足够让灯影和门框把后果照得更近。", + f"{location}里的细节没有再混成一团:茶气往上浮,窗缝发冷,案角那道浅痕也把这一步{scene_function}分出新的棱角。", + ] + return variants[_variant_index(beat, chapter_index=chapter_index, modulo=len(variants))] + + +def _event_anchor(beat: SceneBeat, *, chapter_index: int = 0) -> str: + raw_title = str(getattr(beat.event, "title", "") or "").strip() + if "·" in raw_title: + raw_title = raw_title.split("·", 1)[1].strip() + raw_title = re.sub(r"(?:\s*[·::/|_-]\s*\d+\s*)+$", "", raw_title).strip(" ·::/|_-") + raw_label = str(getattr(beat, "beat_label", "") or "").strip() + if "·" in raw_label: + raw_label = raw_label.split("·", 1)[-1].strip() + if ":" in raw_label: + raw_label = raw_label.split(":", 1)[1].strip() + raw_label = raw_label.lstrip("·- ") + raw_label = re.sub(r"(?:\s*[·::/|_-]\s*\d+\s*)+$", "", raw_label).strip(" ·::/|_-") + raw_title = raw_title.lstrip("·- ") + if not raw_title: + return "" + location = beat.event.location or "眼前这一处" + scene_function = SCENE_FUNCTION_LABELS.get(beat.event.scene_function, beat.event.scene_function.replace("_", " ")) + dramatic_job = str(getattr(beat, "dramatic_job", "") or "pressure") + job_phrase = { + "entry": "先把局势推到台面上的", + "pressure": "把最难回避的那句逼近眼前的", + "pivot": "让场面开始真正转向的", + "aftermath": "在收声以后仍旧压着人心的", + "echo": "顺着回声继续追上来的", + }.get(dramatic_job, "真正压上来的") + anchor_focus = raw_label or raw_title + if any(fragment in anchor_focus for fragment in ["真正要转向", "这一拍留下来的余波"]): + anchor_focus = raw_title + if anchor_focus == location: + anchor_focus = scene_function + physical_markers = [ + "案角", + "门影", + "窗纸", + "杯沿", + "衣袖", + "灯芯", + "阶前风", + "纸页声", + ] + marker = physical_markers[_variant_index(beat, chapter_index=chapter_index, modulo=len(physical_markers))] + variants = [ + f"{location}里的{marker}先动了一下,{job_phrase}{scene_function}便不再像上一回那样散开。", + f"{raw_title}并不算大,可一落进{location},{job_phrase}那层余波就已经没法再轻轻带过去。", + f"{marker}边那点停顿把{anchor_focus}换成了更具体的动作,连{location}的回声都跟着偏了方向。", + f"{location}里的动静先围住{scene_function},{job_phrase}压力没有抬高声量,却把退路压窄了半寸。", + f"{raw_title}落下时,{location}里的灯影和脚步都停了一瞬,像是替{job_phrase}后果先留出位置。", + f"{anchor_focus}没有被一句话带过去,反而顺着{location}里的纸页声和门缝冷意,把{scene_function}推向另一层代价。", + f"{job_phrase}不是一句重话,而是{marker}、脚步和呼吸一起把{scene_function}推到了人物眼前。", + f"{location}先把人钉在原处,随后才轮到{marker}旁的回声慢慢收紧,让这一拍换了走法。", + ] + return variants[_variant_index(beat, chapter_index=chapter_index, modulo=len(variants))] + -def realize_scene_opening(world: WorldBible, beat: SceneBeat, chapter_goal: str, conflict_axis: str) -> str: +def realize_scene_opening( + world: WorldBible, + beat: SceneBeat, + chapter_goal: str, + conflict_axis: str, + *, + chapter_index: int = 0, +) -> str: style_pack = style_pack_from_world(world) opening = style_pack.scene_realization.scene_openings.get(beat.event.scene_function, []) - chosen = opening[0] if opening else f"{chapter_goal}。{scene_atmosphere(world, beat)}" - return " ".join([chosen, f"压下来的先是{conflict_axis},紧跟着便是人物再也躲不开的那一点心意。"]) + if opening: + chosen = opening[_variant_index(beat, chapter_index=chapter_index, modulo=len(opening))] + else: + fallback_openings = [ + f"{chapter_goal}。{scene_atmosphere(world, beat, chapter_index=chapter_index)}", + f"{scene_atmosphere(world, beat, chapter_index=chapter_index)} {chapter_goal}在这一刻终于逼近了明处。", + f"{chapter_goal}被轻轻推开了一道口子,{scene_atmosphere(world, beat, chapter_index=chapter_index)}", + f"{scene_atmosphere(world, beat, chapter_index=chapter_index)} {chapter_goal}没有沿着上一回的路走,反而从场面里另一处细响开始收紧。", + f"{chapter_goal}先落在眼前的器物、脚步和回声里,随后才变成谁也绕不开的一句真话。", + f"{scene_atmosphere(world, beat, chapter_index=chapter_index)} 这一回先改变的不是声量,而是{chapter_goal}压住人物动作的方式。", + ] + chosen = fallback_openings[_variant_index(beat, chapter_index=chapter_index, modulo=len(fallback_openings))] + pressure_variants = style_pack.scene_realization.scene_pressures.get(beat.event.scene_function, []) + pressure = ( + pressure_variants[_variant_index(beat, chapter_index=chapter_index, modulo=len(pressure_variants))] + if pressure_variants + else "" + ) + suffixes = [ + f"压下来的先是{conflict_axis},紧跟着便是人物再也躲不开的那一点心意。", + f"先被推到眼前的是{conflict_axis},随后才是那层更难承认的真心。", + f"{conflict_axis}先落在场面上,真正迟一步追上来的,却是人心里更难藏的那句真话。", + f"{conflict_axis}没有再停成抽象的难题,而是落到谁先移步、谁先开口、谁先认账的细处。", + f"这一次先变紧的是{conflict_axis}背后的动作顺序,人物每退半步都会把后果推得更近。", + f"{conflict_axis}被灯影和脚步分成了新的岔口,谁也没法再用上一回的沉默把它遮住。", + f"真正压住人的不是同一句解释,而是{conflict_axis}在此刻换了形状,逼人物用新的动作接住它。", + ] + opening_detail = _opening_detail(beat, chapter_index=chapter_index) + return " ".join( + [ + chosen, + pressure, + suffixes[_variant_index(beat, chapter_index=chapter_index, modulo=len(suffixes))], + opening_detail, + ] + ) def realize_beat(world: WorldBible, state_before: NarrativeState, beat: SceneBeat, *, repeated: bool) -> str: - return " ".join( + chapter_index = int(getattr(state_before, "chapter_index", 0) or 0) + longform_compact = chapter_index >= 20 + fragments = { + "emotion": compose_emotion_action(world, state_before, beat, repeated=repeated), + "dialogue": compose_dialogue(world, state_before, beat, repeated=repeated), + "detail": scene_detail(world, beat, repeated=repeated, chapter_index=chapter_index), + } + orders = ( [ - compose_emotion_action(world, beat, repeated=repeated), - compose_dialogue(world, state_before, beat, repeated=repeated), - scene_detail(world, beat, repeated=repeated), + ["dialogue", "emotion", "detail"], + ["dialogue", "detail", "emotion"], + ["emotion", "dialogue", "detail"], + ] + if longform_compact + else [ + ["emotion", "dialogue", "detail"], + ["detail", "emotion", "dialogue"], + ["dialogue", "emotion", "detail"], ] ) + order = orders[_variant_index(beat, chapter_index=chapter_index, modulo=len(orders))] + anchor = _event_anchor(beat, chapter_index=chapter_index) + if longform_compact and _variant_index(beat, chapter_index=chapter_index, modulo=3) != 0: + anchor = "" + body = " ".join([fragments[key] for key in order if fragments.get(key)]) + if anchor: + return " ".join([anchor, body]).strip() + return body -def realize_hook(world: WorldBible, ending_hook: str, scene_function: str) -> str: +def realize_hook(world: WorldBible, ending_hook: str, scene_function: str, *, chapter_index: int = 0) -> str: style_pack = style_pack_from_world(world) hook = style_pack.scene_realization.scene_hooks.get(scene_function, []) if hook: - return hook[0] - return f"等人声慢慢静下去时,留下来的并不是哪一句话更重,而是{ending_hook}。那一点没说尽的情绪已经追到下一次开口之前。" + chapter_seed = int(chapter_index) + seed = sum(ord(char) for char in f"{scene_function}:{ending_hook}") + chapter_seed * 5 + (chapter_seed // 40) * 11 + return hook[seed % len(hook)] + variants = [ + f"等人声慢慢静下去时,留下来的并不是哪一句话更重,而是{ending_hook}。那一点没说尽的情绪已经追到下一次开口之前。", + f"场面虽然先停住了,可真正留下来的还是{ending_hook}。下一次再见时,这句余波不会自己散掉。", + f"话音落下去以后,最先追上来的仍是{ending_hook}。真正难收的那点情绪已经压到下一章门口。", + f"灯影和脚步都慢下来以后,真正没有退开的仍是{ending_hook}。下一次开口前,它会先换一种方式逼近。", + f"人声收住时,{ending_hook}没有跟着收住,只从门边、案角和未完的动作里继续往前压。", + f"这一场先停在这里,可{ending_hook}已经换成了更具体的余波,等下一次见面时不会再按原样回来。", + f"最后静下来的不是心思,而是场面里那点声音;{ending_hook}仍旧留在人物还没做完的动作里。", + ] + chapter_seed = int(chapter_index) + seed = sum(ord(char) for char in f"{scene_function}:{ending_hook}") + chapter_seed * 5 + (chapter_seed // 40) * 11 + return variants[seed % len(variants)] diff --git a/src/narrativeos/core/sensory_grounding.py b/src/narrativeos/core/sensory_grounding.py index 4cc8072..6f40fc6 100644 --- a/src/narrativeos/core/sensory_grounding.py +++ b/src/narrativeos/core/sensory_grounding.py @@ -3,35 +3,198 @@ from ..models import SceneBeat, WorldBible from .contracts import style_pack_from_world +DETAIL_MARKERS = [ + "灯", "袖", "茶", "风", "门", "阶", "檐", "影", "衣", "案", "纸", "雨", "香", "窗", "灯影", + "栏", "栏杆", "杯", "杯沿", "门框", "木板", "纸页", "桌沿", "桌角", "器物", "石径", "叶影", + "扫描台", "蓝线", "红灯", "防潮盒", "钝印", "胶痕", "签章", "声纹", "画稿", "盐壳", "录音笔", "话筒", + "石砖", "空杯", "窗纸", "木栏", "地板", "檐角", "冷光", "回声", "香灰", "笔架", "卷面", "号板", + "墨迹", "鞋底", "手背", "发梢", "灰尘", "水痕", "潮气", "湿气", "衣摆", "袖口", +] +SCENE_FUNCTION_LABELS = { + "false_peace": "表面平静", + "temptation": "试探", + "truth_trial": "真相逼近", + "mask_crack": "裂口", + "confession_window": "真话窗口", + "debt_exchange": "旧账回潮", + "karma_ripening": "因果回响", + "humiliation": "难堪代价", + "vow_payment": "誓言偿付", + "misrecognition": "误解升级", +} + def _pick_line(lines: list[str], index: int) -> str: return lines[index % len(lines)] if lines else "" -def scene_atmosphere(world: WorldBible, beat: SceneBeat) -> str: +def _detail_marker_count(text: str) -> int: + return sum(str(text or "").count(marker) for marker in DETAIL_MARKERS) + + +def _beat_seed(beat: SceneBeat, *, chapter_index: int = 0, extra: int = 0) -> int: + event_id = str(getattr(beat.event, "event_id", "") or "") + title = str(getattr(beat.event, "title", "") or "") + scene_function = str(getattr(beat.event, "scene_function", "") or "") + beat_index = int(getattr(beat, "beat_index", 0) or 0) + return sum(ord(char) for char in f"{event_id}:{title}:{scene_function}") + beat_index * 17 + int(chapter_index) * 31 + int(extra) + + +def _scene_quality_contract(beat: SceneBeat) -> dict[str, object]: + metadata = dict(getattr(beat.event, "metadata", {}) or {}) + return dict(metadata.get("scene_quality_contract") or {}) + + +def _keyword_anchors(text: str) -> dict[str, list[str]]: + normalized = str(text or "") + anchors = { + "object": [], + "sound": [], + "body_motion": [], + "ambient_signal": [], + "object_state": [], + } + keyword_map = { + "档": {"object": ["防潮盒", "扫描台", "空白页"], "sound": ["锁扣声", "提示音"], "object_state": ["胶痕", "潮痕", "签章"]}, + "录音": {"object": ["录音带", "声纹图", "纸签"], "sound": ["磁带轻响", "回放底噪"], "object_state": ["水渍", "卷边"]}, + "签": {"object": ["签字页", "签章", "纸页"], "object_state": ["折痕", "焦痕", "墨迹"]}, + "考": {"object": ["号板", "卷面", "笔架"], "sound": ["落笔声", "木板轻响"], "ambient_signal": ["汗气", "墨香"]}, + "卷": {"object": ["卷面", "朱批", "笔架"], "sound": ["落笔声", "翻卷声"], "object_state": ["墨迹", "折角"]}, + "庭": {"object": ["廊柱", "石阶", "花枝"], "sound": ["帘钩声", "玉佩轻响"], "ambient_signal": ["香灰", "冷光"]}, + "殿": {"object": ["灯座", "玉阶", "香炉"], "sound": ["钟声", "衣袂轻响"], "ambient_signal": ["檀香", "冷雾"], "object_state": ["裂纹"]}, + "山门": {"object": ["山门石阶", "剑穗", "符纸"], "sound": ["钟磬声", "衣袂声"], "ambient_signal": ["云气", "霜意"]}, + "镜湖": {"object": ["湖面碎光", "灯座", "石栏"], "sound": ["水声", "风过铃声"], "ambient_signal": ["水雾", "月色"]}, + "花": {"object": ["花枝", "石径", "湿叶"], "sound": ["叶响", "鞋底轻擦声"], "ambient_signal": ["湿气", "灯色"]}, + "窗": {"object": ["窗纸", "杯沿", "门框"], "sound": ["窗缝风声", "杯沿轻响"], "ambient_signal": ["冷光"]}, + "港": {"object": ["栏杆", "船绳", "石板"], "sound": ["浪声", "缆绳摩擦声"], "ambient_signal": ["水气", "盐味"], "object_state": ["盐壳"]}, + "潮": {"object": ["防潮盒", "盐壳", "水线"], "sound": ["浪声", "水滴声"], "ambient_signal": ["潮气", "盐味"], "object_state": ["潮痕"]}, + "巷": {"object": ["雨棚", "旧门牌", "监控探头"], "sound": ["电流声", "鞋底水声"], "ambient_signal": ["霓虹冷光", "潮墙气"]}, + "莲": {"object": ["莲纹砖", "雨伞骨", "玻璃柜"], "sound": ["雨棚滴水", "路灯电流"], "ambient_signal": ["湿雾", "油烟冷味"]}, + } + for keyword, typed_values in keyword_map.items(): + if keyword not in normalized: + continue + for anchor_type, values in typed_values.items(): + anchors[anchor_type].extend(str(value) for value in values) + return anchors + + +def _location_anchor_pool(location: str, scene_function: str, anchor_type: str) -> list[str]: + normalized_location = str(location or "") + scene_label = SCENE_FUNCTION_LABELS.get(scene_function, scene_function.replace("_", " ")) + base = { + "object": ["杯沿", "门框", "纸页", "桌沿", "案角", "栏杆", "灯座", "石阶", "防潮盒", "雨棚"], + "sound": ["回声", "轻响", "翻页声", "风声", "脚步声", "器物碰响", "落笔声", "水滴声", "钟声", "电流声"], + "body_motion": ["指节", "衣袖", "呼吸", "脚步", "发梢", "肩背", "掌心", "手背", "衣摆", "眼睫"], + "ambient_signal": ["灯影", "冷光", "潮气", "香气", "灰尘", "湿意", "檀香", "云气", "盐味", "霓虹冷光"], + "object_state": ["折痕", "磨痕", "裂口", "水痕", "胶痕", "潮痕", "盐壳", "卷边", "钝印", "裂纹"], + } + keyword_anchors = _keyword_anchors(f"{normalized_location} {scene_label}") + specific = list(keyword_anchors.get(anchor_type) or []) + generic = list(base.get(anchor_type) or []) + merged = specific + (generic[:4] if specific else generic) + deduped: list[str] = [] + for value in merged: + candidate = str(value).strip() + if candidate and candidate not in deduped: + deduped.append(candidate) + return deduped + + +def _pick_anchor(pool: list[str], *, seed: int, fallback: str) -> str: + if not pool: + return fallback + return pool[seed % len(pool)] + + +def _detail_enrichment_tail(location: str, scene_function: str, *, variant_index: int) -> str: + label = SCENE_FUNCTION_LABELS.get(scene_function, scene_function.replace("_", " ")) + variants = [ + f"{location}边的窗、门、灯影、衣袖和案角纸页一起压上来,连茶气、脚步声和桌边那一下轻响都把这一步{label}里的分寸照得更清。", + f"风从{location}外掠过门檐,带得窗边、阶前、衣角和桌面都起了一层轻微的动静,连灯下那点影子、香气和纸页翻动都把这场{label}压得更近。", + f"{location}里的灯、纸、窗、袖影、门缝和回声并没有安静下来,反而在风声、脚步和桌沿碰响里一点点把这一步{label}的重量托了出来。", + f"{location}里那点雨味、灰尘、灯火和门边冷气一起贴上来,连衣摆扫过地面的动静都像替这一步{label}把后劲拖长了一寸。", + f"越靠近{location}里面,窗纸、杯沿、门框、灯影和衣袖摩擦出的细响越清,像所有东西都在替这一步{label}记账。", + ] + return variants[variant_index % len(variants)] + + +def _dynamic_detail_tail(beat: SceneBeat, *, chapter_index: int = 0, variant_index: int = 0) -> str: + location = beat.event.location or "眼前这一处" + scene_function = beat.event.scene_function + label = SCENE_FUNCTION_LABELS.get(scene_function, scene_function.replace("_", " ")) + contract = _scene_quality_contract(beat) + anchor_types = list(contract.get("detail_anchor_types") or ["object", "sound", "body_motion", "ambient_signal"]) + if "object" not in anchor_types: + anchor_types.insert(0, "object") + if "sound" not in anchor_types: + anchor_types.append("sound") + if "body_motion" not in anchor_types: + anchor_types.append("body_motion") + if "ambient_signal" not in anchor_types: + anchor_types.append("ambient_signal") + seed = _beat_seed(beat, chapter_index=chapter_index, extra=variant_index) + + object_pool = _location_anchor_pool(location, scene_function, "object") + list(_keyword_anchors(f"{beat.event.title} {beat.event.summary}").get("object") or []) + sound_pool = _location_anchor_pool(location, scene_function, "sound") + list(_keyword_anchors(f"{beat.event.title} {beat.event.summary}").get("sound") or []) + body_pool = _location_anchor_pool(location, scene_function, "body_motion") + ambient_pool = _location_anchor_pool(location, scene_function, "ambient_signal") + state_pool = _location_anchor_pool(location, scene_function, "object_state") + list(_keyword_anchors(f"{beat.event.title} {beat.event.summary}").get("object_state") or []) + + object_a = _pick_anchor(object_pool, seed=seed, fallback="杯沿") + object_b = _pick_anchor(object_pool, seed=seed + 3, fallback="门框") + sound = _pick_anchor(sound_pool, seed=seed + 5, fallback="回声") + body = _pick_anchor(body_pool, seed=seed + 7, fallback="指节") + ambient = _pick_anchor(ambient_pool, seed=seed + 11, fallback="灯影") + state = _pick_anchor(state_pool, seed=seed + 13, fallback="折痕") + + variants = [ + f"{location}里{ambient}贴着{object_a}、{object_b}和{body}一起逼近,连{sound}都把这一步{label}压成了摸得着的重量,连{state}也被灯下那层冷意照了出来。", + f"{location}边的{object_a}、{object_b}和{body}先撞出一点细响,{ambient}顺着门边压回来,让{sound}把这一步{label}真正钉在了{state}还没散开的那一层上。", + f"越往{location}里面走,{ambient}就越贴着{object_a}、{object_b}和{body}不放,连{sound}和{state}都开始替这一步{label}留下具体的痕。", + f"{location}里先显出来的不是谁的脸色,而是{object_a}、{object_b}、{body}和{ambient}一起把{sound}压得更清,连{state}都像在替这一步{label}记账。", + ] + return variants[(seed + len(anchor_types)) % len(variants)] + + +def scene_atmosphere(world: WorldBible, beat: SceneBeat, *, chapter_index: int = 0) -> str: style_pack = style_pack_from_world(world) location = beat.event.location or "generic" beat_index = getattr(beat, "beat_index", 0) + event_seed = sum(ord(char) for char in str(getattr(beat.event, "event_id", "") or "")) + variant_index = beat_index + event_seed + int(chapter_index) * 3 slots = style_pack.sensory_grounding.location_slots.get(location, {}) atmosphere = slots.get("atmosphere", []) if atmosphere: - return _pick_line(atmosphere, beat_index) + return _pick_line(atmosphere, variant_index) generic = style_pack.sensory_grounding.generic_slots.get("atmosphere", []) if generic: - return _pick_line(generic, beat_index) + return _pick_line(generic, variant_index) return f"{location}里并不安静,连空气都像在替谁压住一口没说完的话。" -def scene_detail(world: WorldBible, beat: SceneBeat, *, repeated: bool) -> str: +def scene_detail(world: WorldBible, beat: SceneBeat, *, repeated: bool, chapter_index: int = 0) -> str: style_pack = style_pack_from_world(world) location = beat.event.location or "generic" beat_index = getattr(beat, "beat_index", 0) + event_seed = sum(ord(char) for char in str(getattr(beat.event, "event_id", "") or "")) + variant_index = beat_index + event_seed + int(chapter_index) * 3 slots = style_pack.sensory_grounding.location_slots.get(location, {}) key = "repeat_detail" if repeated else "detail" details = slots.get(key, []) - if details: - return _pick_line(details, beat_index) generic = style_pack.sensory_grounding.generic_slots.get(key, []) - if generic: - return _pick_line(generic, beat_index) - return "灯影和衣角的轻微动静都被这一场沉默压得更清。" + if details: + chosen = _pick_line(details, variant_index) + elif generic: + chosen = _pick_line(generic, variant_index) + else: + chosen = "灯影和衣角的轻微动静都被这一场沉默压得更清。" + location = beat.event.location or "眼前这一处" + extras: list[str] = [] + if not repeated or _detail_marker_count(chosen) < 8: + extras.append(_detail_enrichment_tail(location, beat.event.scene_function, variant_index=variant_index)) + if not repeated or _detail_marker_count(chosen) < 10: + extras.append(_dynamic_detail_tail(beat, chapter_index=chapter_index, variant_index=variant_index)) + if extras: + chosen = " ".join([chosen.rstrip(), *extras]).strip() + return chosen diff --git a/src/narrativeos/core/writer.py b/src/narrativeos/core/writer.py index f6ebae8..a1b0819 100644 --- a/src/narrativeos/core/writer.py +++ b/src/narrativeos/core/writer.py @@ -1,5 +1,6 @@ from __future__ import annotations +from time import perf_counter from typing import List from ..models import ChapterDraft, NarrativeState, SceneBeat, ScenePlan, SceneRenderSpec, WorldBible @@ -20,6 +21,10 @@ "xianxia": "誓愿与天命", "suspense": "悬疑与压迫", "synthetic": "试探与选择", + "near_future_harbor_mystery": "港城真相与失落记忆", + "ensemble_drama": "群像关系与互相牵制", + "conspiracy": "阴谋与被遮蔽的代价", + "memory_trade": "记忆交易与迟来的追账", } @@ -67,39 +72,61 @@ def write_chapter_draft( scene_beats[0], scene_plan.chapter_goal, scene_plan.conflict_axes[0] if scene_plan.conflict_axes else "局势", + chapter_index=int(getattr(state_before, "chapter_index", 0) or 0), ) paragraphs = [opening] previous_event_id = None + previous_scene_function = None for beat in scene_beats: paragraphs.append( realize_beat( world, state_before, beat, - repeated=previous_event_id == beat.event.event_id, + repeated=( + previous_event_id == beat.event.event_id + or previous_scene_function == beat.event.scene_function + ), ) ) previous_event_id = beat.event.event_id + previous_scene_function = beat.event.scene_function if scene_plan.ending_hook: - paragraphs.append(realize_hook(world, scene_plan.ending_hook, scene_beats[-1].event.scene_function)) + paragraphs.append( + realize_hook( + world, + scene_plan.ending_hook, + scene_beats[-1].event.scene_function, + chapter_index=int(getattr(state_before, "chapter_index", 0) or 0), + ) + ) body = "\n\n".join(paragraphs) draft = ChapterDraft( body=body, paragraphs=paragraphs, dialogue_count=body.count("“"), - action_count=sum(body.count(marker) for marker in ["抬", "落", "偏", "按", "推", "站", "看", "握", "停", "拢"]), + action_count=sum(body.count(marker) for marker in ["抬", "落", "偏", "按", "推", "站", "看", "握", "停", "拢", "压", "掠", "碰", "擦", "收", "绷", "卷", "撞", "回", "拨", "绕", "贴", "拖"]), detail_count=sum(body.count(marker) for marker in ["灯", "袖", "茶", "风", "窗", "案", "影", "香", "光", "声", "纸"]), metadata={ "target_word_count": render_spec.target_word_count, + "min_target_word_count": render_spec.min_target_word_count, + "max_target_word_count": render_spec.max_target_word_count, "scene_goal": scene_plan.scene_goal, "beat_count": len(scene_beats), }, ) - return repair_chapter_draft( + repair_started = perf_counter() + repaired = repair_chapter_draft( world=world, state_before=state_before, scene_plan=scene_plan, scene_beats=scene_beats, draft=draft, + render_spec=render_spec, ) + repair_elapsed_ms = round((perf_counter() - repair_started) * 1000.0, 3) + repair_timing = dict(repaired.metadata.get("quality_pass_timing_ms") or {}) + repair_timing["total_ms"] = repair_elapsed_ms + repaired.metadata["quality_pass_timing_ms"] = repair_timing + return repaired diff --git a/src/narrativeos/eval/scorers.py b/src/narrativeos/eval/scorers.py index bf01c33..9159b1c 100644 --- a/src/narrativeos/eval/scorers.py +++ b/src/narrativeos/eval/scorers.py @@ -3,10 +3,28 @@ from typing import Iterable, List, Sequence from ..models import EvaluationIssue, EvaluationScores, NarrativeState, SceneBeat -from ..repetition_detector import repetition_score +from ..prose_linter import story_text_unit_count +from ..repetition_detector import repetition_score, repetition_signal_bundle from .taxonomy import ISSUE_TAXONOMY +LONGFORM_SOFT_ISSUE_THRESHOLDS = { + "q04_exposition_threshold": 0.5, + "q05_detail_density_threshold": 1.0 / 220.0, + "q05_scene_density_threshold": 0.34, + "q09_pacing_threshold": 0.34, + "q09_hook_threshold": 0.42, +} + +SHORTFORM_SOFT_ISSUE_THRESHOLDS = { + "q04_exposition_threshold": 0.44, + "q05_detail_density_threshold": 1.0 / 180.0, + "q05_scene_density_threshold": 0.42, + "q09_pacing_threshold": 0.45, + "q09_hook_threshold": 0.45, +} + + def _clamp(value: float, lower: float = 0.0, upper: float = 1.0) -> float: return max(lower, min(upper, value)) @@ -45,8 +63,37 @@ def causal_continuity(issues: Sequence[EvaluationIssue]) -> float: return 0.88 -def pacing(ending_ready: bool, state_after: NarrativeState, repetition: float) -> float: - score = 0.82 - repetition +def _repetition_pressure(bundle: dict[str, object]) -> float: + lexical = float(bundle.get("lexical_repetition_score", 0.0) or 0.0) + semantic = float(bundle.get("semantic_paragraph_similarity_score", 0.0) or 0.0) + paragraph_similarity = float(bundle.get("paragraph_similarity_score", 0.0) or 0.0) + n_gram = float(bundle.get("n_gram_repetition_score", 0.0) or 0.0) + beat_structure = float(bundle.get("beat_structure_repetition_score", 0.0) or 0.0) + event_coverage_gap = float(bundle.get("event_coverage_gap_score", 0.0) or 0.0) + beat_coverage_gap = float(bundle.get("beat_coverage_gap_score", 0.0) or 0.0) + suspicious_refrain = min(1.0, float(int(bundle.get("suspicious_refrain_count", 0) or 0)) / 8.0) + coverage_pressure = min( + 1.0, + max(event_coverage_gap, beat_coverage_gap, float(int(bundle.get("uncovered_beat_count", 0) or 0)) / 3.0, float(int(bundle.get("overcovered_beat_count", 0) or 0)) / 3.0), + ) + return max( + lexical * 0.45, + semantic, + paragraph_similarity * 0.7, + n_gram * 0.35, + beat_structure * 0.85, + suspicious_refrain, + coverage_pressure, + ) + + +def pacing(ending_ready: bool, state_after: NarrativeState, repetition_pressure: float, *, text_unit_count: int) -> float: + repetition_penalty = repetition_pressure * (0.55 if text_unit_count >= 1800 else 1.0) + score = 0.82 - repetition_penalty + if text_unit_count >= 1800: + score += 0.06 + if len(state_after.open_promises) > 0: + score += 0.04 if len(state_after.open_promises) == 0 and not ending_ready and state_after.chapter_index < state_after.min_end_turn: score -= 0.18 if ending_ready and state_after.chapter_index < state_after.min_end_turn: @@ -80,7 +127,7 @@ def hook_quality(body: str) -> float: tail = body.split("\n\n")[-1] if any(token in tail for token in ["总结", "完成", "这一章", "从这里起", "放远一点看"]): return 0.22 - if any(token in tail for token in ["下一次", "还会", "还没", "追上来", "没有散", "未说尽"]): + if any(token in tail for token in ["下一次", "下一章", "还会", "还没", "追上来", "没有散", "未说尽", "未完", "余波"]): return 0.9 return 0.45 @@ -98,21 +145,35 @@ def derive_scoring_issues( scores: EvaluationScores, exposition_ratio: float, concrete_detail_density: float, + text_unit_count: int, ending_ready: bool, state_after: NarrativeState, ) -> List[EvaluationIssue]: issues: List[EvaluationIssue] = [] - if exposition_ratio > 0.44: + longform_chapter = text_unit_count >= 1800 + thresholds = LONGFORM_SOFT_ISSUE_THRESHOLDS if longform_chapter else SHORTFORM_SOFT_ISSUE_THRESHOLDS + exposition_threshold = float(thresholds["q04_exposition_threshold"]) + if not longform_chapter and str(getattr(state_after, "story_phase", "") or "") == "setup": + exposition_threshold = max(exposition_threshold, 0.55) + detail_density_threshold = float(thresholds["q05_detail_density_threshold"]) + scene_density_threshold = float(thresholds["q05_scene_density_threshold"]) + pacing_threshold = float(thresholds["q09_pacing_threshold"]) + hook_threshold = float(thresholds["q09_hook_threshold"]) + if exposition_ratio > exposition_threshold: issues.append( EvaluationIssue( issue_code="Q04", severity="medium", summary="解释句比例偏高,场面推进感不足。", owning_module=ISSUE_TAXONOMY["Q04"]["owning_module"], - evidence=["exposition_ratio=%.3f" % exposition_ratio], + evidence=[ + "exposition_ratio=%.3f" % exposition_ratio, + "threshold=%.3f" % exposition_threshold, + "text_units=%s" % text_unit_count, + ], ) ) - if scores.scene_density < 0.42 or concrete_detail_density < (1.0 / 180.0): + if scores.scene_density < scene_density_threshold or concrete_detail_density < detail_density_threshold: issues.append( EvaluationIssue( issue_code="Q05", @@ -122,6 +183,9 @@ def derive_scoring_issues( evidence=[ "scene_density=%.3f" % scores.scene_density, "detail_density=%.4f" % concrete_detail_density, + "scene_density_threshold=%.3f" % scene_density_threshold, + "detail_density_threshold=%.4f" % detail_density_threshold, + "text_units=%s" % text_unit_count, ], ) ) @@ -135,7 +199,10 @@ def derive_scoring_issues( evidence=["choice_distinctness=%.3f" % scores.choice_distinctness], ) ) - if scores.pacing < 0.45 or scores.hook_quality < 0.45 or (ending_ready and state_after.chapter_index < state_after.min_end_turn): + q09_due_to_ending = ending_ready and state_after.chapter_index < state_after.min_end_turn + q09_due_to_pacing = scores.pacing < pacing_threshold + q09_due_to_hook = scores.hook_quality < hook_threshold and len(state_after.open_promises) == 0 + if q09_due_to_ending or q09_due_to_pacing or q09_due_to_hook: severity = "high" if ending_ready and state_after.chapter_index < state_after.min_end_turn else "medium" issues.append( EvaluationIssue( @@ -146,6 +213,10 @@ def derive_scoring_issues( evidence=[ "pacing=%.3f" % scores.pacing, "hook_quality=%.3f" % scores.hook_quality, + "pacing_threshold=%.3f" % pacing_threshold, + "hook_threshold=%.3f" % hook_threshold, + "text_units=%s" % text_unit_count, + "open_promises=%s" % len(state_after.open_promises), "chapter_index=%s" % state_after.chapter_index, ], ) @@ -186,12 +257,18 @@ def score_chapter( choices: Sequence[str], paywall_required: bool, ) -> EvaluationScores: - repetition = repetition_score(body.split("\n\n")) + repetition_bundle = repetition_signal_bundle(body.split("\n\n")) + text_unit_count = story_text_unit_count(body) readability_score = readability(body) scene_density_score = scene_density(dialogue_count, action_count, detail_count, body) fidelity_score = character_fidelity(character_fidelity_score) continuity_score = causal_continuity(issues) - pacing_score = pacing(ending_ready, state_after, repetition) + pacing_score = pacing( + ending_ready, + state_after, + _repetition_pressure(repetition_bundle), + text_unit_count=text_unit_count, + ) choice_score = choice_distinctness(choices) hook_score = hook_quality(body) monetize_score = monetize_ready(choice_score, body, paywall_required) diff --git a/src/narrativeos/eval/service.py b/src/narrativeos/eval/service.py index 3f07989..2c65377 100644 --- a/src/narrativeos/eval/service.py +++ b/src/narrativeos/eval/service.py @@ -1,14 +1,253 @@ from __future__ import annotations -from typing import Sequence +from typing import Any, Dict, Optional, Sequence +from ..content_quality_contracts import ( + evaluate_chapter_quality_contract, + resolve_chapter_task_quality_contract_from_coverage, + resolve_scene_function_from_coverage, + resolve_scene_quality_contract_from_coverage, +) from ..core.linter import lint_chapter_draft from ..models import EvaluationIssue, EvaluationReport, NarrativeState, SceneBeat +from ..prose_linter import extract_latin_token_hits from .reporting import build_evaluation_report from .scorers import derive_scoring_issues, score_chapter from .validators import run_hard_validators +CHAPTER_QUALITY_GUARD_FAILURE_CODE = "chapter_quality_guard_failed" + + +class ChapterQualityGuardError(ValueError): + def __init__(self, quality_gate: Dict[str, Any]) -> None: + self.quality_gate = dict(quality_gate) + super().__init__(CHAPTER_QUALITY_GUARD_FAILURE_CODE) + + +def _required_text_units_for_persistence( + *, + target_words: Optional[int] = None, + min_target_words: Optional[int] = None, +) -> int: + if min_target_words is not None: + try: + return max(0, int(min_target_words)) + except (TypeError, ValueError): + return 0 + if target_words is not None: + try: + return max(0, int(round(float(target_words) * 0.9))) + except (TypeError, ValueError): + return 0 + return 0 + + +def _safe_int(value: Any) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def build_chapter_quality_gate( + *, + report: EvaluationReport, + target_words: Optional[int] = None, + min_target_words: Optional[int] = None, + latin_token_hits: Optional[Sequence[Dict[str, Any]]] = None, + chapter_index: Optional[int] = None, + target_chapters: Optional[int] = None, + story_phase: Optional[str] = None, + scene_quality_contract: Optional[Dict[str, Any]] = None, + chapter_task_quality_contract: Optional[Dict[str, Any]] = None, + rolling_quality_window: Optional[Sequence[Dict[str, Any]]] = None, + scene_function: str = "", + chapter_task_id: str = "", + ending_ready: bool = False, + enforcement_scope: str = "persisted_chapter", +) -> Dict[str, Any]: + lint_metrics = dict((report.hard_validator_results or {}).get("lint_metrics") or {}) + actual_text_units = int(lint_metrics.get("text_unit_count") or 0) + required_text_units = _required_text_units_for_persistence( + target_words=target_words, + min_target_words=min_target_words, + ) + decision = str((report.decision or {}).decision if report.decision else "") + latin_hits = [dict(item or {}) for item in list(latin_token_hits or [])] + disallowed_latin_hits = [item for item in latin_hits if not bool(item.get("allowed"))] + failed_checks = [] + if required_text_units and actual_text_units < required_text_units: + failed_checks.append("text_unit_floor_not_met") + if disallowed_latin_hits: + failed_checks.append("disallowed_latin_token_detected") + if decision != "pass": + failed_checks.append("decision_not_pass") + issues = [issue.to_dict() for issue in list(report.issues or [])] + owning_modules = sorted( + { + str(item.get("owning_module") or "").strip() + for item in issues + if isinstance(item, dict) and str(item.get("owning_module") or "").strip() + } + ) + if "text_unit_floor_not_met" in failed_checks and "writer" not in owning_modules: + owning_modules.append("writer") + if "disallowed_latin_token_detected" in failed_checks and "writer" not in owning_modules: + owning_modules.append("writer") + contract_gate = evaluate_chapter_quality_contract( + report=report, + chapter_index=int(chapter_index or 0), + target_chapters=int(target_chapters or 0), + story_phase=str(story_phase or ""), + scene_quality_contract=scene_quality_contract, + chapter_task_quality_contract=chapter_task_quality_contract, + rolling_quality_window=rolling_quality_window, + scene_function=scene_function, + chapter_task_id=chapter_task_id, + ending_ready=ending_ready, + enforcement_scope=enforcement_scope, + ) + failed_contract_checks = list(contract_gate.get("failed_contract_checks") or []) + all_failed_checks = list(failed_checks) + failed_contract_checks + primary_issue_group = str(contract_gate.get("primary_issue_group") or "") + disallowed_fields = sorted({str(item.get("field") or "") for item in disallowed_latin_hits if str(item.get("field") or "").strip()}) + disallowed_tokens = list(dict.fromkeys(str(item.get("token") or "") for item in disallowed_latin_hits if str(item.get("token") or "").strip())) + summary = report.summary + if disallowed_tokens: + summary = "reader 可见文本包含未允许的英文 token:%s" % " / ".join(disallowed_tokens[:5]) + elif failed_contract_checks: + summary = "章节未通过共享内容质量 contract:%s" % " / ".join(failed_contract_checks[:3]) + enforced_decision = decision + if decision == "block": + enforced_decision = "block" + elif all_failed_checks: + should_block = primary_issue_group == "Q09" and float(contract_gate.get("completion_ratio", 1.0) or 1.0) < 0.96 + should_block = should_block or any( + item in failed_contract_checks for item in ("rolling_window_repeat_breach", "rolling_window_exposition_breach") + ) + enforced_decision = "block" if should_block else "rewrite" + return { + "ok": not all_failed_checks, + "code": CHAPTER_QUALITY_GUARD_FAILURE_CODE, + "decision": decision, + "enforced_decision": enforced_decision, + "target_words": _safe_int(target_words), + "min_target_words": _safe_int(min_target_words), + "required_text_units": required_text_units, + "actual_text_units": actual_text_units, + "issues": issues, + "scores": report.scores.to_dict(), + "owning_modules": owning_modules, + "failed_checks": all_failed_checks, + "summary": summary, + "latin_token_hits": latin_hits, + "disallowed_latin_token_hits": disallowed_latin_hits, + "latin_token_fields": disallowed_fields, + "latin_token_tokens": disallowed_tokens, + "latin_token_whitelist_rule": "uppercase_acronyms_only", + "contract_checks": list(contract_gate.get("contract_checks") or []), + "contract_thresholds": dict(contract_gate.get("contract_thresholds") or {}), + "primary_issue_group": primary_issue_group, + "primary_asset_target": dict(contract_gate.get("primary_asset_target") or {}), + "window_breach_kind": str(contract_gate.get("window_breach_kind") or ""), + "blocking_dimension": str(contract_gate.get("blocking_dimension") or primary_issue_group), + "enforcement_scope": str(contract_gate.get("enforcement_scope") or enforcement_scope), + "quality_contract_window": list(contract_gate.get("quality_contract_window") or []), + } + + +def evaluate_persisted_chapter( + *, + chapter_id: str, + world_version_id: str, + session_id: str, + body: str, + paragraphs: Sequence[str], + dialogue_count: int, + action_count: int, + detail_count: int, + character_fidelity_score: float, + state_after: NarrativeState, + ending_ready: bool, + chapter_title: Optional[str] = None, + recap: Optional[str] = None, + relationship_hints: Optional[Sequence[str]] = None, + choices: Sequence[str], + paywall_required: bool, + coverage_context: Optional[Dict[str, Any]] = None, + target_words: Optional[int] = None, + min_target_words: Optional[int] = None, + chapter_index: Optional[int] = None, + target_chapters: Optional[int] = None, + story_phase: Optional[str] = None, + scene_quality_contract: Optional[Dict[str, Any]] = None, + chapter_task_quality_contract: Optional[Dict[str, Any]] = None, + rolling_quality_window: Optional[Sequence[Dict[str, Any]]] = None, + enforcement_scope: str = "persisted_chapter", +) -> Dict[str, Any]: + report = evaluate_chapter( + chapter_id=chapter_id, + world_version_id=world_version_id, + session_id=session_id, + body=body, + paragraphs=paragraphs, + dialogue_count=dialogue_count, + action_count=action_count, + detail_count=detail_count, + character_fidelity_score=character_fidelity_score, + state_after=state_after, + ending_ready=ending_ready, + choices=choices, + paywall_required=paywall_required, + coverage_context=coverage_context, + ) + latin_token_hits = extract_latin_token_hits(body, field="body") + if chapter_title: + latin_token_hits.extend(extract_latin_token_hits(chapter_title, field="chapter_title")) + if recap: + latin_token_hits.extend(extract_latin_token_hits(recap, field="recap")) + for index, relationship_hint in enumerate(list(relationship_hints or []), start=1): + latin_token_hits.extend(extract_latin_token_hits(str(relationship_hint or ""), field=f"relationship_hint_{index}")) + for index, choice in enumerate(list(choices or []), start=1): + latin_token_hits.extend(extract_latin_token_hits(str(choice or ""), field=f"choice_{index}")) + quality_gate = build_chapter_quality_gate( + report=report, + target_words=target_words, + min_target_words=min_target_words, + latin_token_hits=latin_token_hits, + chapter_index=chapter_index if chapter_index is not None else int(state_after.chapter_index or 0), + target_chapters=target_chapters, + story_phase=story_phase if story_phase is not None else str(state_after.story_phase or ""), + scene_quality_contract=scene_quality_contract or resolve_scene_quality_contract_from_coverage(coverage_context), + chapter_task_quality_contract=chapter_task_quality_contract or resolve_chapter_task_quality_contract_from_coverage(coverage_context), + rolling_quality_window=rolling_quality_window or list((state_after.metadata or {}).get("quality_contract_window", [])), + scene_function=resolve_scene_function_from_coverage(coverage_context), + chapter_task_id=str(dict((coverage_context or {}).get("chapter_task") or {}).get("chapter_task_id") or ""), + ending_ready=ending_ready, + enforcement_scope=enforcement_scope, + ) + return { + "report": report, + "quality_gate": quality_gate, + } + + +def apply_quality_gate_to_report(report: EvaluationReport, quality_gate: Dict[str, Any]) -> Dict[str, Any]: + payload = report.to_dict() + payload["quality_gate"] = dict(quality_gate or {}) + if not quality_gate.get("ok", False): + payload["decision"] = { + **dict(payload.get("decision") or {}), + "decision": quality_gate.get("enforced_decision") or "rewrite", + "reason": CHAPTER_QUALITY_GUARD_FAILURE_CODE, + } + payload["summary"] = str(quality_gate.get("summary") or "章节未通过持久化硬约束。") + return payload + + def evaluate_chapter( *, chapter_id: str, @@ -24,6 +263,7 @@ def evaluate_chapter( ending_ready: bool, choices: Sequence[str], paywall_required: bool, + coverage_context: Optional[Dict[str, Any]] = None, ) -> EvaluationReport: lint_report = lint_chapter_draft(body) hard = run_hard_validators( @@ -34,6 +274,7 @@ def evaluate_chapter( detail_count=detail_count or int(lint_report["detail_count"]), state_after=state_after, ending_ready=ending_ready, + coverage_context=coverage_context, ) issues: list[EvaluationIssue] = [EvaluationIssue.from_dict(item) for item in hard["issues"]] scores = score_chapter( @@ -52,6 +293,7 @@ def evaluate_chapter( scores=scores, exposition_ratio=float(lint_report["exposition_ratio"]), concrete_detail_density=float(lint_report["concrete_detail_density"]), + text_unit_count=int(lint_report.get("text_unit_count") or 0), ending_ready=ending_ready, state_after=state_after, ) @@ -69,9 +311,29 @@ def evaluate_chapter( "meta_sentence_rate": lint_report["meta_sentence_rate"], "engineering_leak_rate": lint_report["engineering_leak_rate"], "repetition_score": lint_report["repetition_score"], + "repetition_signal_bundle": { + **dict(lint_report.get("repetition_signal_bundle") or {}), + **dict(hard.get("repetition_signal_bundle") or {}), + }, + "lexical_repetition_score": (hard.get("repetition_signal_bundle") or {}).get("lexical_repetition_score", lint_report.get("lexical_repetition_score", 0.0)), + "paragraph_similarity_score": (hard.get("repetition_signal_bundle") or {}).get("paragraph_similarity_score", lint_report.get("paragraph_similarity_score", 0.0)), + "semantic_paragraph_similarity_score": (hard.get("repetition_signal_bundle") or {}).get("semantic_paragraph_similarity_score", 0.0), + "n_gram_repetition_score": (hard.get("repetition_signal_bundle") or {}).get("n_gram_repetition_score", lint_report.get("n_gram_repetition_score", 0.0)), + "beat_structure_repetition_score": (hard.get("repetition_signal_bundle") or {}).get("beat_structure_repetition_score", lint_report.get("beat_structure_repetition_score", 0.0)), + "suspicious_refrain_count": (hard.get("repetition_signal_bundle") or {}).get("suspicious_refrain_count", lint_report.get("suspicious_refrain_count", 0)), + "event_coverage_gap_score": (hard.get("repetition_signal_bundle") or {}).get("event_coverage_gap_score", 0.0), + "beat_coverage_gap_score": (hard.get("repetition_signal_bundle") or {}).get("beat_coverage_gap_score", 0.0), + "uncovered_event_count": (hard.get("repetition_signal_bundle") or {}).get("uncovered_event_count", 0), + "uncovered_beat_count": (hard.get("repetition_signal_bundle") or {}).get("uncovered_beat_count", 0), + "overcovered_beat_count": (hard.get("repetition_signal_bundle") or {}).get("overcovered_beat_count", 0), + "semantic_paragraph_similarity_pairs": (hard.get("repetition_signal_bundle") or {}).get("semantic_paragraph_similarity_pairs", []), + "coverage_gap_examples": (hard.get("repetition_signal_bundle") or {}).get("coverage_gap_examples", []), "exposition_ratio": lint_report["exposition_ratio"], "dialogue_plus_action_ratio": lint_report["dialogue_plus_action_ratio"], "concrete_detail_density": lint_report["concrete_detail_density"], + "text_unit_count": lint_report.get("text_unit_count", 0), + "latin_token_hits": lint_report.get("latin_token_hits", []), + "disallowed_latin_token_hits": lint_report.get("disallowed_latin_token_hits", []), }, }, ) diff --git a/src/narrativeos/eval/validators.py b/src/narrativeos/eval/validators.py index d0aebdb..f037532 100644 --- a/src/narrativeos/eval/validators.py +++ b/src/narrativeos/eval/validators.py @@ -4,11 +4,32 @@ from ..meta_leak_detector import detect_meta_leaks from ..models import EvaluationIssue, NarrativeState -from ..repetition_detector import repetition_score +from ..prose_linter import extract_latin_token_hits, story_text_unit_count +from ..repetition_detector import repetition_signal_bundle from ..style_sanitizer import style_sanitize from .taxonomy import ISSUE_TAXONOMY +LONGFORM_Q03_SIGNAL_THRESHOLDS = { + "semantic_paragraph_similarity_score": 0.84, + "event_coverage_gap_score": 0.5, + "beat_coverage_gap_score": 0.42, + "uncovered_beat_count": 1, + "overcovered_beat_count": 2, + "hard_paragraph_similarity_score": 0.93, + "hard_n_gram_repetition_score": 0.65, + "hard_suspicious_refrain_count": 5, +} + +SHORTFORM_Q03_SIGNAL_THRESHOLDS = { + "lexical_repetition_score": 0.16, + "paragraph_similarity_score": 0.88, + "n_gram_repetition_score": 0.15, + "beat_structure_repetition_score": 0.7, + "suspicious_refrain_count": 2, +} + + def _issue(code: str, severity: str, summary: str, evidence: List[str]) -> EvaluationIssue: return EvaluationIssue( issue_code=code, @@ -35,11 +56,90 @@ def meta_narration_validator(text: str) -> List[EvaluationIssue]: return [_issue("Q02", "high", "正文仍然带有策划/元叙事口吻。", meta_hits)] -def paragraph_repetition_validator(paragraphs: Iterable[str]) -> List[EvaluationIssue]: - score = repetition_score(paragraphs) - if score <= 0.16: +def paragraph_repetition_validator( + paragraphs: Iterable[str], + *, + text_unit_count_value: int, + coverage_context: Dict[str, object] | None = None, + precomputed_bundle: Dict[str, object] | None = None, +) -> List[EvaluationIssue]: + bundle = dict(precomputed_bundle or repetition_signal_bundle(paragraphs, coverage_context=coverage_context)) + context = dict(coverage_context or {}) + chapter_task = dict(context.get("chapter_task") or {}) + longform_context = bool(chapter_task) and int(chapter_task.get("target_words", 0) or 0) >= 1500 + if text_unit_count_value >= 1800 or (text_unit_count_value >= 1500 and longform_context): + medium_hits: List[str] = [] + repetition_hits: List[str] = [] + coverage_hits: List[str] = [] + if float(bundle.get("semantic_paragraph_similarity_score", 0.0) or 0.0) >= LONGFORM_Q03_SIGNAL_THRESHOLDS["semantic_paragraph_similarity_score"]: + medium_hits.append("semantic_paragraph_similarity") + repetition_hits.append("semantic_paragraph_similarity") + if float(bundle.get("event_coverage_gap_score", 0.0) or 0.0) >= LONGFORM_Q03_SIGNAL_THRESHOLDS["event_coverage_gap_score"]: + medium_hits.append("event_coverage_gap") + coverage_hits.append("event_coverage_gap") + if float(bundle.get("beat_coverage_gap_score", 0.0) or 0.0) >= LONGFORM_Q03_SIGNAL_THRESHOLDS["beat_coverage_gap_score"]: + medium_hits.append("beat_coverage_gap") + coverage_hits.append("beat_coverage_gap") + if int(bundle.get("uncovered_beat_count", 0) or 0) >= LONGFORM_Q03_SIGNAL_THRESHOLDS["uncovered_beat_count"]: + medium_hits.append("uncovered_beat") + coverage_hits.append("uncovered_beat") + if int(bundle.get("overcovered_beat_count", 0) or 0) >= LONGFORM_Q03_SIGNAL_THRESHOLDS["overcovered_beat_count"]: + medium_hits.append("overcovered_beat") + coverage_hits.append("overcovered_beat") + if int(bundle.get("suspicious_refrain_count", 0) or 0) >= 2: + medium_hits.append("suspicious_refrain") + repetition_hits.append("suspicious_refrain") + hard_hit = ( + float(bundle.get("paragraph_similarity_score", 0.0) or 0.0) >= LONGFORM_Q03_SIGNAL_THRESHOLDS["hard_paragraph_similarity_score"] + or ( + float(bundle.get("n_gram_repetition_score", 0.0) or 0.0) >= LONGFORM_Q03_SIGNAL_THRESHOLDS["hard_n_gram_repetition_score"] + and int(bundle.get("suspicious_refrain_count", 0) or 0) >= LONGFORM_Q03_SIGNAL_THRESHOLDS["hard_suspicious_refrain_count"] + ) + ) + coverage_only = bool(coverage_hits) and not repetition_hits + if coverage_only and not hard_hit: + return [] + if not hard_hit and len(medium_hits) < 2: + return [] + evidence = [ + "lexical_repetition_score=%.3f" % float(bundle.get("lexical_repetition_score", 0.0) or 0.0), + "semantic_paragraph_similarity_score=%.3f" % float(bundle.get("semantic_paragraph_similarity_score", 0.0) or 0.0), + "paragraph_similarity_score=%.3f" % float(bundle.get("paragraph_similarity_score", 0.0) or 0.0), + "n_gram_repetition_score=%.3f" % float(bundle.get("n_gram_repetition_score", 0.0) or 0.0), + "beat_structure_repetition_score=%.3f" % float(bundle.get("beat_structure_repetition_score", 0.0) or 0.0), + "event_coverage_gap_score=%.3f" % float(bundle.get("event_coverage_gap_score", 0.0) or 0.0), + "beat_coverage_gap_score=%.3f" % float(bundle.get("beat_coverage_gap_score", 0.0) or 0.0), + "uncovered_event_count=%s" % int(bundle.get("uncovered_event_count", 0) or 0), + "uncovered_beat_count=%s" % int(bundle.get("uncovered_beat_count", 0) or 0), + "overcovered_beat_count=%s" % int(bundle.get("overcovered_beat_count", 0) or 0), + "suspicious_refrain_count=%s" % int(bundle.get("suspicious_refrain_count", 0) or 0), + "trigger_signals=%s" % (",".join(medium_hits) or "hard_signal"), + "selected_event_ids=%s" % ",".join(str(item) for item in bundle.get("selected_event_ids", [])[:6]), + "coverage_gap_examples=%s" % str(bundle.get("coverage_gap_examples", [])[:3]), + ] + return [_issue("Q03", "medium", "章节存在结构性回环,疑似靠重复扩写撑长。", evidence)] + if ( + float(bundle.get("lexical_repetition_score", 0.0) or 0.0) <= SHORTFORM_Q03_SIGNAL_THRESHOLDS["lexical_repetition_score"] + and float(bundle.get("paragraph_similarity_score", 0.0) or 0.0) <= SHORTFORM_Q03_SIGNAL_THRESHOLDS["paragraph_similarity_score"] + and float(bundle.get("n_gram_repetition_score", 0.0) or 0.0) <= SHORTFORM_Q03_SIGNAL_THRESHOLDS["n_gram_repetition_score"] + and float(bundle.get("beat_structure_repetition_score", 0.0) or 0.0) <= SHORTFORM_Q03_SIGNAL_THRESHOLDS["beat_structure_repetition_score"] + and int(bundle.get("suspicious_refrain_count", 0) or 0) < SHORTFORM_Q03_SIGNAL_THRESHOLDS["suspicious_refrain_count"] + ): return [] - return [_issue("Q03", "medium", "章节段落重复感偏高。", ["repetition_score=%.3f" % score])] + return [ + _issue( + "Q03", + "medium", + "章节段落重复感偏高。", + [ + "lexical_repetition_score=%.3f" % float(bundle.get("lexical_repetition_score", 0.0) or 0.0), + "paragraph_similarity_score=%.3f" % float(bundle.get("paragraph_similarity_score", 0.0) or 0.0), + "n_gram_repetition_score=%.3f" % float(bundle.get("n_gram_repetition_score", 0.0) or 0.0), + "beat_structure_repetition_score=%.3f" % float(bundle.get("beat_structure_repetition_score", 0.0) or 0.0), + "suspicious_refrain_count=%s" % int(bundle.get("suspicious_refrain_count", 0) or 0), + ], + ) + ] def chapter_structure_validator( @@ -88,11 +188,22 @@ def run_hard_validators( detail_count: int, state_after: NarrativeState, ending_ready: bool, + coverage_context: Dict[str, object] | None = None, ) -> Dict[str, object]: issues: List[EvaluationIssue] = [] + unit_count = story_text_unit_count(text) + repetition_bundle = repetition_signal_bundle(paragraphs, coverage_context=coverage_context) + latin_token_hits = extract_latin_token_hits(text, field="body") issues.extend(engineering_leak_validator(text)) issues.extend(meta_narration_validator(text)) - issues.extend(paragraph_repetition_validator(paragraphs)) + issues.extend( + paragraph_repetition_validator( + paragraphs, + text_unit_count_value=unit_count, + coverage_context=coverage_context, + precomputed_bundle=repetition_bundle, + ) + ) issues.extend( chapter_structure_validator( text=text, @@ -112,4 +223,7 @@ def run_hard_validators( return { "issues": [issue.to_dict() for issue in issues], "failed": any(issue.severity == "high" for issue in issues), + "repetition_signal_bundle": repetition_bundle, + "latin_token_hits": latin_token_hits, + "disallowed_latin_token_hits": [item for item in latin_token_hits if not item["allowed"]], } diff --git a/src/narrativeos/long_route_quality.py b/src/narrativeos/long_route_quality.py new file mode 100644 index 0000000..629f64a --- /dev/null +++ b/src/narrativeos/long_route_quality.py @@ -0,0 +1,456 @@ +from __future__ import annotations + +import re +from collections import Counter +from copy import deepcopy +from typing import Any, Dict, Iterable, List, Sequence, Tuple + + +LONG_ROUTE_QUALITY_METADATA_KEY = "long_route_quality_budget" + +DEFAULT_READER_CHOICE = "顺着此刻的局势先退半步,再找一个更稳的开口。" + +STOCK_REFRAIN_REPLACEMENTS: Dict[str, Sequence[str]] = { + "眼前这一处": ("眼前", "这道裂口", "当前线索"), + "这一处": ("这里", "此刻", "眼前", "这道裂口"), + "真话窗口": ("开口的时机", "那道短暂的缝隙", "能说实话的一刻"), + "把每一步都接住": ("把眼前这一步稳住", "先守住当前的转圜", "让下一步落在实处"), + "别再漏掉": ("别再放过关键处", "不能再让线索滑开", "别让这处空过去"), + "真正要转向的那句终于逼到眼前": ("那句该说的话终于贴近眼前", "局面逼出必须回应的一句", "被拖住的回答终于到了近前"), + "被压回去的": ("没说出口的", "被藏住的", "被按下去的"), + DEFAULT_READER_CHOICE: ( + "先稳住眼前这一处,再顺着露出的线索追下去。", + "先接住当前的变化,再换一个更清楚的问法。", + "先把局面收稳,再逼近下一处没有说完的地方。", + ), +} +STOCK_REFRAIN_KEEP_LIMITS: Dict[str, int] = { + "眼前这一处": 1, + "这一处": 2, +} + +BROKEN_SLOT_PATTERNS: Sequence[Tuple[re.Pattern[str], str]] = ( + (re.compile(r"被压回去的\s*[、,]\s*"), "被压回去的话,"), + (re.compile(r"(?P[\u4e00-\u9fff]{2,12}的)\s*[、,]\s*(?=(?:并|也|才|就|却|仍|还|没有|不|把|被|让|在|从|沿|往))"), r"\g事,"), + (re.compile(r"(?P[\u4e00-\u9fff]{2,12}的)\s*[、,]\s*(?=[。!?;\n]|$)"), r"\g事"), +) +META_VISIBLE_REPLACEMENTS: Sequence[Tuple[re.Pattern[str], str]] = ( + (re.compile(r"如果把这一章放远一点看"), "把眼前这一幕放远一点看"), + (re.compile(r"这一章"), "这一幕"), + (re.compile(r"从这里起"), "从这里开始"), + (re.compile(r"更糟的是"), "更紧的是"), + (re.compile(r"真正厉害的是"), "真正压住人的地方在于"), +) +SCENE_CARD_TEXT_FIELDS = ("title", "summary", "quote", "pull_quote") +SCENE_CARD_LIST_FIELDS = ("story_beats", "beats", "visual_details") + +LOCATION_ANCHOR_PER_CHAPTER_MAX = 3 +LOCATION_ANCHOR_GLOBAL_SOFT_MAX = 150 +STOCK_REFRAIN_GLOBAL_MAX = 8 +CHOICE_TEXT_GLOBAL_MAX = 2 +RECENT_CHOICE_WINDOW = 40 + + +def _normalize_text(value: str) -> str: + return re.sub(r"\s+", "", str(value or "").strip()) + + +def _stable_index(seed: int, size: int) -> int: + if size <= 0: + return 0 + return abs(int(seed or 0)) % size + + +def clean_broken_reader_slots(text: str) -> Tuple[str, Dict[str, Any]]: + cleaned = str(text or "") + repairs: List[Dict[str, Any]] = [] + for pattern, replacement in BROKEN_SLOT_PATTERNS: + matches = list(pattern.finditer(cleaned)) + if not matches: + continue + cleaned = pattern.sub(replacement, cleaned) + repairs.append({"pattern": pattern.pattern, "count": len(matches)}) + cleaned = re.sub(r"\s+([,。!?;:、,.!?])", r"\1", cleaned) + cleaned = re.sub(r"([,、]){2,}", r"\1", cleaned) + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + return cleaned.strip(), {"broken_slot_repairs": repairs, "broken_slot_repaired": bool(repairs)} + + +def clean_reader_visible_meta_language(text: str) -> Tuple[str, Dict[str, Any]]: + cleaned = str(text or "") + repairs: List[Dict[str, Any]] = [] + for pattern, replacement in META_VISIBLE_REPLACEMENTS: + matches = list(pattern.finditer(cleaned)) + if not matches: + continue + cleaned = pattern.sub(replacement, cleaned) + repairs.append({"pattern": pattern.pattern, "count": len(matches), "replacement": replacement}) + return cleaned.strip(), {"meta_language_repairs": repairs, "meta_language_repaired": bool(repairs)} + + +def _coerce_beat_payload(raw: Any) -> Dict[str, Any]: + if hasattr(raw, "to_dict"): + raw = raw.to_dict() + payload = dict(raw or {}) + event = payload.get("event") or {} + if hasattr(event, "to_dict"): + event = event.to_dict() + payload["event"] = dict(event or {}) + return payload + + +def _extract_location_anchors(coverage_context: Dict[str, Any] | None) -> List[str]: + anchors: List[str] = [] + for raw in list(dict(coverage_context or {}).get("scene_beats") or []): + event = _coerce_beat_payload(raw).get("event") or {} + location = _normalize_text(str(dict(event).get("location") or "")) + if 2 <= len(location) <= 16 and location not in {"未指定", "场面", "眼前"}: + anchors.append(location) + return list(dict.fromkeys(anchors)) + + +def _replace_from_occurrence(text: str, phrase: str, replacement: str, *, keep: int) -> Tuple[str, int]: + if not phrase: + return text, 0 + seen = 0 + replaced = 0 + + def repl(match: re.Match[str]) -> str: + nonlocal seen, replaced + seen += 1 + if seen <= keep: + return match.group(0) + replaced += 1 + return replacement + + cleaned = re.sub(re.escape(phrase), repl, text) + return cleaned, replaced + + +def _apply_stock_refrain_budget(text: str, counts: Dict[str, int], *, chapter_index: int) -> Tuple[str, List[Dict[str, Any]]]: + cleaned = str(text or "") + actions: List[Dict[str, Any]] = [] + for phrase, replacements in STOCK_REFRAIN_REPLACEMENTS.items(): + occurrences = cleaned.count(phrase) + if occurrences <= 0: + continue + normalized = _normalize_text(phrase) + previous = int(counts.get(normalized, 0) or 0) + max_keep = int(STOCK_REFRAIN_KEEP_LIMITS.get(phrase, STOCK_REFRAIN_GLOBAL_MAX)) + keep = max(0, max_keep - previous) + replacement = str(replacements[_stable_index(chapter_index + previous, len(replacements))]) + cleaned, replaced = _replace_from_occurrence(cleaned, phrase, replacement, keep=keep) + counts[normalized] = previous + occurrences + if replaced: + actions.append({"kind": "stock_refrain_budget", "phrase": phrase, "replaced": replaced, "previous": previous}) + return cleaned, actions + + +def _apply_location_anchor_budget( + text: str, + anchors: Sequence[str], + counts: Dict[str, int], + *, + chapter_index: int, +) -> Tuple[str, List[Dict[str, Any]]]: + cleaned = str(text or "") + actions: List[Dict[str, Any]] = [] + replacements = ("那里", "旧处", "眼前", "那处") + for anchor in anchors: + phrase = str(anchor or "").strip() + occurrences = cleaned.count(phrase) + if occurrences <= 0: + continue + normalized = _normalize_text(phrase) + previous = int(counts.get(normalized, 0) or 0) + keep = LOCATION_ANCHOR_PER_CHAPTER_MAX + if previous >= LOCATION_ANCHOR_GLOBAL_SOFT_MAX: + keep = min(1, occurrences) + replacement = replacements[_stable_index(chapter_index + previous, len(replacements))] + cleaned, replaced = _replace_from_occurrence(cleaned, phrase, replacement, keep=keep) + counts[normalized] = previous + occurrences + if replaced: + actions.append({"kind": "location_anchor_budget", "phrase": phrase, "replaced": replaced, "previous": previous}) + return cleaned, actions + + +def _choice_replacement(*, index: int, chapter_index: int, anchor: str) -> str: + anchor_text = anchor or "眼前这一处" + templates = ( + "先稳住{anchor}里露出的变化,再追问下一处空白。", + "换一个角度逼近{anchor},把没有说完的地方接住。", + "暂时不退,顺着{anchor}的裂口继续往前问。", + "先护住当前线索,再把{anchor}里的旧账翻到明处。", + "沿着{anchor}留下的细节往下查,不急着替任何人收场。", + "把{anchor}里的停顿问清楚,再决定下一步要压向谁。", + "先看清{anchor}里谁在回避,再把问题换到更实的一处。", + "不急着表态,先让{anchor}里的证据自己露出下一层。", + "顺着{anchor}的异常往前推,看看旧账会落到谁手里。", + "把{anchor}里最轻的破绽扣住,逼对方给出更具体的回答。", + "先绕开场面话,直接追问{anchor}里没有对上的细节。", + "守住{anchor}这一线,再把迟迟没人认的代价翻出来。", + ) + return templates[_stable_index(chapter_index + index, len(templates))].format(anchor=anchor_text) + + +def _diversify_choices( + choices: Iterable[str], + *, + counts: Dict[str, int], + recent_choices: Sequence[str], + chapter_index: int, + anchor: str, +) -> Tuple[List[str], List[Dict[str, Any]]]: + output: List[str] = [] + actions: List[Dict[str, Any]] = [] + current_seen: Counter[str] = Counter() + recent_set = {_normalize_text(item) for item in recent_choices if _normalize_text(item)} + for index, raw in enumerate(list(choices or []), start=1): + cleaned, slot_report = clean_broken_reader_slots(str(raw or "")) + cleaned, meta_report = clean_reader_visible_meta_language(cleaned) + cleaned, stock_actions = _apply_stock_refrain_budget(cleaned, counts, chapter_index=chapter_index) + normalized = _normalize_text(cleaned) + previous = int(counts.get(f"choice:{normalized}", 0) or 0) + must_replace = ( + not normalized + or normalized == _normalize_text(DEFAULT_READER_CHOICE) + or normalized in recent_set + or previous >= CHOICE_TEXT_GLOBAL_MAX + or current_seen[normalized] > 0 + ) + if must_replace: + replacement = _choice_replacement(index=index, chapter_index=chapter_index, anchor=anchor) + suffix = 1 + while _normalize_text(replacement) in {_normalize_text(item) for item in output}: + suffix += 1 + replacement = _choice_replacement(index=index + suffix, chapter_index=chapter_index, anchor=anchor) + actions.append({"kind": "choice_budget", "previous_text": cleaned, "replacement": replacement, "previous": previous}) + cleaned = replacement + normalized = _normalize_text(cleaned) + if slot_report.get("broken_slot_repaired"): + actions.append({"kind": "choice_broken_slot_repair", "index": index, **slot_report}) + if meta_report.get("meta_language_repaired"): + actions.append({"kind": "choice_meta_language_repair", "index": index, **meta_report}) + actions.extend(stock_actions) + counts[f"choice:{normalized}"] = int(counts.get(f"choice:{normalized}", 0) or 0) + 1 + current_seen[normalized] += 1 + output.append(cleaned) + return output, actions + + +def _repair_visible_text_field( + value: str, + *, + field: str, + phrase_counts: Dict[str, int], + anchors: Sequence[str], + chapter_index: int, +) -> Tuple[str, List[Dict[str, Any]]]: + cleaned, slot_report = clean_broken_reader_slots(str(value or "")) + cleaned, meta_report = clean_reader_visible_meta_language(cleaned) + cleaned, anchor_actions = _apply_location_anchor_budget(cleaned, anchors, phrase_counts, chapter_index=chapter_index) + cleaned, stock_actions = _apply_stock_refrain_budget(cleaned, phrase_counts, chapter_index=chapter_index) + actions: List[Dict[str, Any]] = [] + if slot_report.get("broken_slot_repaired"): + actions.append({"kind": "broken_slot_repair", "field": field, **slot_report}) + if meta_report.get("meta_language_repaired"): + actions.append({"kind": "meta_language_repair", "field": field, **meta_report}) + actions.extend({"field": field, **item} for item in stock_actions + anchor_actions) + return cleaned, actions + + +def repair_reader_view_for_display( + reader_view: Dict[str, Any], + *, + source: str = "legacy_read_projection", +) -> Dict[str, Any]: + """Repair legacy stored reader-visible text without mutating source quality evidence.""" + working = deepcopy(dict(reader_view or {})) + phrase_counts: Dict[str, int] = {} + actions: List[Dict[str, Any]] = [] + chapter_index = int(working.get("chapter_index", 0) or 0) + + for field in ("chapter_title", "recap", "body"): + if field not in working: + continue + cleaned, field_actions = _repair_visible_text_field( + str(working.get(field) or ""), + field=field, + phrase_counts=phrase_counts, + anchors=[], + chapter_index=chapter_index, + ) + working[field] = cleaned + actions.extend(field_actions) + + relationship_hints = [] + for index, raw in enumerate(list(working.get("relationship_hints") or []), start=1): + cleaned, field_actions = _repair_visible_text_field( + str(raw or ""), + field=f"relationship_hints[{index}]", + phrase_counts=phrase_counts, + anchors=[], + chapter_index=chapter_index, + ) + relationship_hints.append(cleaned) + actions.extend(field_actions) + if "relationship_hints" in working: + working["relationship_hints"] = relationship_hints + + scene_card = dict(working.get("scene_card") or {}) + if scene_card: + for key in SCENE_CARD_TEXT_FIELDS: + if key not in scene_card: + continue + cleaned, field_actions = _repair_visible_text_field( + str(scene_card.get(key) or ""), + field=f"scene_card.{key}", + phrase_counts=phrase_counts, + anchors=[], + chapter_index=chapter_index, + ) + scene_card[key] = cleaned + actions.extend(field_actions) + for key in SCENE_CARD_LIST_FIELDS: + if key not in scene_card: + continue + repaired_items = [] + for index, raw in enumerate(list(scene_card.get(key) or []), start=1): + cleaned, field_actions = _repair_visible_text_field( + str(raw or ""), + field=f"scene_card.{key}[{index}]", + phrase_counts=phrase_counts, + anchors=[], + chapter_index=chapter_index, + ) + repaired_items.append(cleaned) + actions.extend(field_actions) + scene_card[key] = repaired_items + working["scene_card"] = scene_card + + if "choices" in working: + repaired_choices = [] + for index, raw in enumerate(list(working.get("choices") or []), start=1): + cleaned, field_actions = _repair_visible_text_field( + str(raw or ""), + field=f"choices[{index}]", + phrase_counts=phrase_counts, + anchors=[], + chapter_index=chapter_index, + ) + repaired_choices.append(cleaned) + actions.extend(field_actions) + working["choices"] = repaired_choices + + if actions: + working["display_sanitization"] = { + "schema_version": "reader_view_display_sanitization/v1", + "source": source, + "repaired": True, + "actions": actions[-20:], + } + return working + + +def apply_long_route_quality_controls( + reader_view: Dict[str, Any], + *, + state_before: Any, + state_after: Any, + coverage_context: Dict[str, Any] | None = None, +) -> Tuple[Dict[str, Any], Any, Dict[str, Any]]: + working = deepcopy(dict(reader_view or {})) + before_metadata = dict(getattr(state_before, "metadata", {}) or {}) + budget = dict(before_metadata.get(LONG_ROUTE_QUALITY_METADATA_KEY) or {}) + phrase_counts = {str(key): int(value or 0) for key, value in dict(budget.get("phrase_counts") or {}).items()} + choice_counts = {str(key): int(value or 0) for key, value in dict(budget.get("choice_counts") or {}).items()} + recent_choices = [str(item) for item in list(budget.get("recent_choices") or []) if str(item).strip()] + chapter_index = int(getattr(state_after, "chapter_index", 0) or 0) + anchors = _extract_location_anchors(coverage_context) + primary_anchor = anchors[0] if anchors else "" + actions: List[Dict[str, Any]] = [] + + for field in ("chapter_title", "recap", "body"): + cleaned, field_actions = _repair_visible_text_field( + str(working.get(field) or ""), + field=field, + phrase_counts=phrase_counts, + anchors=anchors, + chapter_index=chapter_index, + ) + working[field] = cleaned + actions.extend(field_actions) + + relationship_hints = [] + for index, raw in enumerate(list(working.get("relationship_hints") or []), start=1): + field_name = f"relationship_hints[{index}]" + cleaned, field_actions = _repair_visible_text_field( + str(raw or ""), + field=field_name, + phrase_counts=phrase_counts, + anchors=anchors, + chapter_index=chapter_index, + ) + relationship_hints.append(cleaned) + actions.extend(field_actions) + working["relationship_hints"] = relationship_hints + + scene_card = dict(working.get("scene_card") or {}) + if scene_card: + for key in SCENE_CARD_TEXT_FIELDS: + if key not in scene_card: + continue + field_name = f"scene_card.{key}" + cleaned, field_actions = _repair_visible_text_field( + str(scene_card.get(key) or ""), + field=field_name, + phrase_counts=phrase_counts, + anchors=anchors, + chapter_index=chapter_index, + ) + scene_card[key] = cleaned + actions.extend(field_actions) + for key in SCENE_CARD_LIST_FIELDS: + if key not in scene_card: + continue + repaired_items = [] + for index, raw in enumerate(list(scene_card.get(key) or []), start=1): + field_name = f"scene_card.{key}[{index}]" + cleaned, field_actions = _repair_visible_text_field( + str(raw or ""), + field=field_name, + phrase_counts=phrase_counts, + anchors=anchors, + chapter_index=chapter_index, + ) + repaired_items.append(cleaned) + actions.extend(field_actions) + scene_card[key] = repaired_items + working["scene_card"] = scene_card + + choices, choice_actions = _diversify_choices( + list(working.get("choices") or []), + counts=choice_counts, + recent_choices=recent_choices, + chapter_index=chapter_index, + anchor=primary_anchor, + ) + working["choices"] = choices + actions.extend(choice_actions) + + updated_recent_choices = (recent_choices + choices)[-RECENT_CHOICE_WINDOW:] + metadata = { + **dict(getattr(state_after, "metadata", {}) or {}), + LONG_ROUTE_QUALITY_METADATA_KEY: { + "phrase_counts": dict(sorted(phrase_counts.items())), + "choice_counts": dict(sorted(choice_counts.items())), + "recent_choices": updated_recent_choices, + "last_actions": actions[-20:], + }, + } + state_after.metadata = metadata + return working, state_after, { + "long_route_quality_controls_applied": bool(actions), + "actions": actions, + "tracked_location_anchors": anchors, + } diff --git a/src/narrativeos/longform.py b/src/narrativeos/longform.py new file mode 100644 index 0000000..6306a33 --- /dev/null +++ b/src/narrativeos/longform.py @@ -0,0 +1,1287 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from .models import LONGFORM_DUTY_TYPES, ChapterPlan, EventAtom, NarrativeState, PromiseLedgerEntry, WorldBible + + +DEFAULT_LONGFORM_WORD_BUDGET = 2000 +ROLLING_RECAP_LIMIT = 8 +LONGFORM_PLAN_KEY = "longform_plan" +LONGFORM_PROGRESS_KEY = "longform_progression" +STORYLINE_CONTRACT_KEY = "series_storyline_contract" +CHARACTER_MEMORY_PROFILES_KEY = "character_memory_profiles" +STEERING_GUARDRAILS_KEY = "steering_guardrails" +MEMORY_COMPRESSION_POLICY_KEY = "memory_compression_policy" +LONGFORM_100_GATE_THRESHOLDS = { + "pass_rate_min": 0.6, + "block_rate_max": 0.0, + "character_drift_rate_max": 0.15, + "promise_unresolved_rate_max": 0.55, + "arc_task_repeat_rate_max": 0.65, + "q09_incidence_rate_max": 0.1, + "mid_arc_pass_rate_min": 0.55, + "continuity_signal_chapters_min": 12, + "mid_arc_signal_completion_ratio_min": 0.33, + "premature_ending_trigger_rate_max": 0.0, + "volume_climax_spacing_error_max": 0.35, +} +DEFAULT_MEMORY_COMPRESSION_POLICY = { + "rolling_recap_limit": 8, + "active_arc_memory_limit": 12, + "archive_retrieval_limit": 12, + "archive_retention_limit": 160, + "series_archive_prune_margin_chapters": 40, + "volume_snapshot_every_n_chapters": 1, + "promote_memory_on_reference_count": 2, + "volume_context_window": 2, + "series_snapshot_every_n_volumes": 2, + "series_snapshot_limit": 3, + "series_ending_activation_window_chapters": 30, + "series_terminal_min_completion_ratio": 0.96, + "timeline_retention_limit": 240, + "continuation_fact_retention_limit": 120, + "continuation_visit_retention_limit": 120, +} + + +def _normalize_storyline_contract( + contract: Optional[Dict[str, Any]], + *, + series_plan: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + payload = dict(contract or {}) + plan = dict(series_plan or {}) + core_storyline = str(payload.get("core_storyline") or plan.get("title") or "").strip() + protected_themes = [ + str(item).strip() + for item in (payload.get("protected_themes") or [plan.get("theme_statement")] if plan.get("theme_statement") else []) + if str(item).strip() + ] + milestone_candidates = list(payload.get("milestones") or []) + milestones = [ + { + "milestone_id": str(item.get("milestone_id") or f"milestone_{index + 1}"), + "label": str(item.get("label") or item.get("goal") or f"Milestone {index + 1}"), + "target_chapter": int(item.get("target_chapter", 0) or 0), + "status": str(item.get("status") or "planned"), + } + for index, item in enumerate(milestone_candidates) + ] + return { + "core_storyline": core_storyline, + "protected_themes": protected_themes, + "no_early_ending": bool(payload.get("no_early_ending", True)), + "milestones": milestones, + "conflict_policy": str(payload.get("conflict_policy") or "reconcile_and_carry_forward"), + "storyline_summary": str(payload.get("storyline_summary") or core_storyline), + } + + +def _default_character_memory_profile(character_id: str, state: NarrativeState) -> Dict[str, Any]: + character = state.characters.get(character_id) + return { + "structured_memory": { + "relationship_history": [], + "promises": [], + "secrets": [], + "scars": [str(character.wound.core_wound)] if character else [], + "faction": "", + "taboos": [], + "goals": list(character.public_goals[:2]) if character else [], + }, + "free_text_memory": [], + "pending_memory_patches": [], + "adopted_memory_patches": [], + } + + +def _normalize_character_memory_profiles( + profiles: Optional[Dict[str, Any]], + *, + state: NarrativeState, +) -> Dict[str, Dict[str, Any]]: + payload = dict(profiles or {}) + normalized: Dict[str, Dict[str, Any]] = {} + for character_id in set(list(state.characters.keys()) + [str(key) for key in payload.keys()]): + entry = dict(payload.get(character_id) or {}) + baseline = _default_character_memory_profile(character_id, state) + structured = dict(baseline.get("structured_memory", {})) + structured.update(dict(entry.get("structured_memory") or {})) + normalized[str(character_id)] = { + "structured_memory": structured, + "free_text_memory": [str(item) for item in entry.get("free_text_memory", baseline.get("free_text_memory", [])) if str(item)], + "pending_memory_patches": [dict(item) for item in entry.get("pending_memory_patches", baseline.get("pending_memory_patches", []))], + "adopted_memory_patches": [dict(item) for item in entry.get("adopted_memory_patches", baseline.get("adopted_memory_patches", []))], + } + return normalized + + +def _normalize_steering_guardrails(guardrails: Optional[Dict[str, Any]]) -> Dict[str, Any]: + payload = dict(guardrails or {}) + return { + "replan_future_only": bool(payload.get("replan_future_only", True)), + "no_past_rewrite": bool(payload.get("no_past_rewrite", True)), + "conflict_policy": str(payload.get("conflict_policy") or "reconcile_and_carry_forward"), + "no_early_ending": bool(payload.get("no_early_ending", True)), + } + + +def configure_interactive_longform_runtime( + state: NarrativeState, + *, + series_storyline_contract: Optional[Dict[str, Any]] = None, + character_memory_profiles: Optional[Dict[str, Any]] = None, + steering_guardrails: Optional[Dict[str, Any]] = None, +) -> NarrativeState: + storyline_contract = _normalize_storyline_contract(series_storyline_contract) + memory_profiles = _normalize_character_memory_profiles(character_memory_profiles, state=state) + guardrails = _normalize_steering_guardrails(steering_guardrails) + state.metadata[STORYLINE_CONTRACT_KEY] = dict(storyline_contract) + state.metadata[CHARACTER_MEMORY_PROFILES_KEY] = {key: dict(value) for key, value in memory_profiles.items()} + state.metadata[STEERING_GUARDRAILS_KEY] = dict(guardrails) + state.storyline_checkpoint = { + "core_storyline": storyline_contract.get("core_storyline", ""), + "protected_themes": list(storyline_contract.get("protected_themes", [])), + "milestone_count": len(storyline_contract.get("milestones", [])), + "last_updated_chapter": int(state.chapter_index or 0), + "latest_steering_summary": "", + "latest_steering_type": "", + } + state.character_memory_runtime = {key: dict(value) for key, value in memory_profiles.items()} + state.replan_checkpoint = { + "status": "idle", + "chapter_index": int(state.chapter_index or 0), + "summary": "", + "future_only": bool(guardrails.get("replan_future_only", True)), + } + if guardrails.get("no_early_ending", True): + state.metadata["series_terminal_ready"] = False + return state + + +def _interactive_contracts_from_state(state: NarrativeState) -> Dict[str, Any]: + metadata = dict(state.metadata or {}) + return { + "series_storyline_contract": dict(state.storyline_checkpoint or metadata.get(STORYLINE_CONTRACT_KEY) or {}), + "character_memory_profiles": dict(state.character_memory_runtime or metadata.get(CHARACTER_MEMORY_PROFILES_KEY) or {}), + "steering_guardrails": dict(metadata.get(STEERING_GUARDRAILS_KEY) or {}), + } + + +def _steering_intent_overrides(summary: str, impacted_characters: List[str], steering_type: str) -> Dict[str, float]: + weights: Dict[str, float] = {} + lowered = summary.lower() + if any(keyword in summary for keyword in ["感情", "关系", "爱", "真心"]) or steering_type == "mild_steer": + weights["romance"] = max(weights.get("romance", 0.0), 0.7) + weights["loyalty"] = max(weights.get("loyalty", 0.0), 0.45) + if any(keyword in summary for keyword in ["真相", "坦白", "秘密"]) or "truth" in lowered: + weights["honesty"] = max(weights.get("honesty", 0.0), 0.75) + weights["selfhood"] = max(weights.get("selfhood", 0.0), 0.45) + if any(keyword in summary for keyword in ["选择", "命运", "代价", "后果"]) or steering_type == "arc_steer": + weights["risk"] = max(weights.get("risk", 0.0), 0.5) + weights["duty"] = max(weights.get("duty", 0.0), 0.45) + if impacted_characters: + weights["curiosity"] = max(weights.get("curiosity", 0.0), 0.55) + return weights + + +def apply_steering_directive( + state: NarrativeState, + steering_directive: Optional[Dict[str, Any]], + *, + world: Optional[WorldBible] = None, +) -> Dict[str, Any]: + directive = dict(steering_directive or {}) + if not directive: + return {"applied": False} + summary = str( + directive.get("current_user_intent") + or directive.get("summary") + or directive.get("arc_goal_shift") + or directive.get("memory_patch_note") + or "reader_steering" + ).strip() + impacted_characters = [ + str(item).strip() + for item in ( + directive.get("impacted_character_ids") + or directive.get("affected_characters") + or [] + ) + if str(item).strip() + ] + if not impacted_characters and state.characters: + impacted_characters = list(state.characters.keys())[:2] + steering_type = str( + directive.get("steering_type") + or ("memory_steer" if directive.get("memory_patch_note") or directive.get("character_memory_patch") else ("arc_steer" if directive.get("affected_arc_id") or directive.get("arc_goal_shift") else "mild_steer")) + ) + steering_id = str(directive.get("steering_id") or f"steer::{state.world_id}::{int(state.chapter_index or 0) + 1}::{len(state.steering_ledger) + 1}") + intent_overrides = dict(directive.get("intent_override") or {}) + intent_overrides.update(_steering_intent_overrides(summary, impacted_characters, steering_type)) + if intent_overrides: + state.player_intent = {**dict(state.player_intent or {}), **intent_overrides} + entry = { + "steering_id": steering_id, + "chapter_index": int(state.chapter_index or 0) + 1, + "steering_type": steering_type, + "summary": summary, + "impacted_character_ids": impacted_characters, + "affected_arc_id": str(directive.get("affected_arc_id") or state.current_arc_id or ""), + "memory_patch_note": str(directive.get("memory_patch_note") or ""), + "intent_override": intent_overrides, + } + state.steering_ledger = [dict(item) for item in state.steering_ledger] + [entry] + state.metadata["payoff_pressure"] = round(min(1.0, float(state.metadata.get("payoff_pressure", 0.0) or 0.0) + 0.08), 3) + state.metadata["recent_cross_pressure"] = True + cross_threads = list(state.metadata.get("cross_pressure_threads", [])) + cross_threads.append( + { + "thread_id": steering_id, + "status": "open", + "summary": summary, + "steering_type": steering_type, + "opened_at_chapter": int(state.chapter_index or 0), + "impacted_characters": impacted_characters, + } + ) + state.metadata["cross_pressure_threads"] = cross_threads[-12:] + if directive.get("memory_patch_note") or directive.get("character_memory_patch"): + memory_note = str(directive.get("memory_patch_note") or directive.get("character_memory_patch") or summary).strip() + for character_id in impacted_characters: + runtime_entry = dict(state.character_memory_runtime.get(character_id) or _default_character_memory_profile(character_id, state)) + pending = list(runtime_entry.get("pending_memory_patches", [])) + pending.append( + { + "patch_id": f"{steering_id}::{character_id}", + "note": memory_note, + "chapter_index": int(state.chapter_index or 0) + 1, + "status": "pending", + } + ) + runtime_entry["pending_memory_patches"] = pending[-10:] + state.character_memory_runtime[character_id] = runtime_entry + if steering_type in {"arc_steer", "memory_steer"}: + chapter_task = dict(state.current_chapter_task or {}) + if chapter_task: + chapter_task["objective"] = f"{chapter_task.get('objective', '推进当前章节。')} 用户引导:{summary}" + chapter_task["promise_actions"] = list(dict.fromkeys(list(chapter_task.get("promise_actions", [])) + ["maintain_continuity", "replan_future_arc"])) + state.current_chapter_task = chapter_task + state.storyline_checkpoint = { + **dict(state.storyline_checkpoint or {}), + "latest_steering_type": steering_type, + "latest_steering_summary": summary, + "last_updated_chapter": int(state.chapter_index or 0) + 1, + } + guardrails = dict(_interactive_contracts_from_state(state).get("steering_guardrails") or {}) + state.replan_checkpoint = { + "status": "triggered" if steering_type in {"arc_steer", "memory_steer"} else "soft", + "summary": summary, + "steering_id": steering_id, + "steering_type": steering_type, + "future_only": bool(guardrails.get("replan_future_only", True)), + "chapter_index": int(state.chapter_index or 0) + 1, + "affected_arc_id": str(directive.get("affected_arc_id") or state.current_arc_id or ""), + } + _record_replan_event( + state, + mode="strong" if steering_type in {"arc_steer", "memory_steer"} else "soft", + reason=f"steering::{steering_type}", + chapter_index=int(state.chapter_index or 0) + 1, + volume_id=state.current_volume_id, + arc_id=state.current_arc_id, + ) + memory_unit = { + "memory_id": f"steering::{steering_id}", + "memory_type": "steering_directive", + "scope": state.current_arc_id or state.current_volume_id or state.current_series_id or "series", + "entity_refs": { + "character": impacted_characters, + "arc": [state.current_arc_id] if state.current_arc_id else [], + "volume": [state.current_volume_id] if state.current_volume_id else [], + }, + "summary": summary, + "importance": 0.85, + "created_chapter": int(state.chapter_index or 0) + 1, + "last_referenced_chapter": int(state.chapter_index or 0) + 1, + "resolution_status": "active", + } + state.active_arc_memory = [dict(item) for item in state.active_arc_memory] + [memory_unit] + return {"applied": True, "entry": entry, "replan_checkpoint": dict(state.replan_checkpoint)} + + +def longform_min_end_turn_floor(total_target_chapters: int) -> int: + target = max(1, int(total_target_chapters)) + return max(12, min(target, int(round(target * float(LONGFORM_100_GATE_THRESHOLDS["mid_arc_signal_completion_ratio_min"]))))) + + +def _longform_plan_from_state(state: NarrativeState, world: Optional[WorldBible] = None) -> Dict[str, Any]: + metadata = dict(state.metadata or {}) + plan = dict(metadata.get(LONGFORM_PLAN_KEY) or {}) + if plan.get("series_plan"): + return { + "series_plan": dict(plan.get("series_plan") or {}), + "volume_plans": [dict(item) for item in plan.get("volume_plans", [])], + "arc_plans": [dict(item) for item in plan.get("arc_plans", [])], + "chapter_budget_policy": dict(plan.get("chapter_budget_policy") or {}), + } + if world is None: + return {} + world_plan = dict((world.creator_controls.metadata or {}).get(LONGFORM_PLAN_KEY) or {}) + if not world_plan: + return {} + return { + "series_plan": dict(world_plan.get("series_plan") or {}), + "volume_plans": [dict(item) for item in world_plan.get("volume_plans", [])], + "arc_plans": [dict(item) for item in world_plan.get("arc_plans", [])], + "chapter_budget_policy": dict(world_plan.get("chapter_budget_policy") or {}), + } + + +def configure_longform_runtime( + state: NarrativeState, + *, + series_plan: Dict[str, Any], + volume_plans: List[Dict[str, Any]], + arc_plans: List[Dict[str, Any]], + chapter_budget_policy: Dict[str, Any], + memory_compression_policy: Optional[Dict[str, Any]] = None, + world: Optional[WorldBible] = None, +) -> NarrativeState: + state.metadata[LONGFORM_PLAN_KEY] = { + "series_plan": dict(series_plan or {}), + "volume_plans": [dict(item) for item in volume_plans or []], + "arc_plans": [dict(item) for item in arc_plans or []], + "chapter_budget_policy": dict(chapter_budget_policy or {}), + } + state.metadata[MEMORY_COMPRESSION_POLICY_KEY] = { + **dict(DEFAULT_MEMORY_COMPRESSION_POLICY), + **dict(memory_compression_policy or {}), + } + state.metadata["longform_plan_enabled"] = bool(series_plan) + total_target_chapters = int(series_plan.get("total_chapter_target", 0) or 0) + if total_target_chapters: + min_turn_floor = longform_min_end_turn_floor(total_target_chapters) + state.min_end_turn = max(int(state.min_end_turn), min_turn_floor) + state.metadata["longform_min_end_turn_floor"] = min_turn_floor + if chapter_budget_policy: + state.word_budget = int(chapter_budget_policy.get("default_target_words") or state.word_budget or DEFAULT_LONGFORM_WORD_BUDGET) + if world is not None: + sync_longform_progression(state, world) + return state + + +def _memory_compression_policy(state: NarrativeState, world: Optional[WorldBible] = None) -> Dict[str, Any]: + metadata = dict(state.metadata or {}) + policy = dict(metadata.get(MEMORY_COMPRESSION_POLICY_KEY) or {}) + if not policy and world is not None: + world_policy = dict((world.creator_controls.metadata or {}).get(MEMORY_COMPRESSION_POLICY_KEY) or {}) + policy = world_policy + return {**dict(DEFAULT_MEMORY_COMPRESSION_POLICY), **policy} + + +def _record_replan_event( + state: NarrativeState, + *, + mode: str, + reason: str, + chapter_index: int, + volume_id: Optional[str], + arc_id: Optional[str], +) -> None: + entry = { + "mode": mode, + "reason": reason, + "chapter_index": int(chapter_index), + "volume_id": volume_id, + "arc_id": arc_id, + } + state.replan_history = [dict(item) for item in state.replan_history] + [entry] + metrics = dict(state.replan_stability_metrics or {}) + metrics["total_replans"] = int(metrics.get("total_replans", 0) or 0) + 1 + counter_key = f"{mode}_replans" + metrics[counter_key] = int(metrics.get(counter_key, 0) or 0) + 1 + state.replan_stability_metrics = metrics + + +def active_replan_debt(state: NarrativeState) -> Dict[str, Any]: + payload = dict((state.metadata or {}).get("replan_debt") or {}) + active_until = int(payload.get("active_until_chapter", 0) or 0) + chapter_index = int(state.chapter_index or 0) + if active_until and chapter_index <= active_until: + return payload + return {} + + +def record_replan_debt( + state: NarrativeState, + *, + chapter_index: int, + issue_codes: Sequence[str], +) -> None: + normalized_issue_codes = [str(item) for item in issue_codes if str(item)] + if not {"Q07", "Q09"} & set(normalized_issue_codes): + return + recent_steering = [ + dict(item) + for item in list(state.steering_ledger or []) + if int(chapter_index) - int(dict(item).get("chapter_index", 0) or 0) <= 10 + ] + if not recent_steering: + return + existing = dict((state.metadata or {}).get("replan_debt") or {}) + intensity = int(existing.get("intensity", 0) or 0) + 1 + state.metadata["replan_debt"] = { + "status": "active", + "issue_codes": sorted(set(list(existing.get("issue_codes") or []) + normalized_issue_codes)), + "last_trigger_chapter": int(chapter_index), + "active_until_chapter": max(int(existing.get("active_until_chapter", 0) or 0), int(chapter_index) + 10), + "intensity": min(3, intensity), + "latest_steering_id": str(recent_steering[-1].get("steering_id") or ""), + "latest_steering_type": str(recent_steering[-1].get("steering_type") or ""), + "reason": "interactive_window_q07_q09_rise", + } + + +def _snapshot_volume_memory( + state: NarrativeState, + *, + volume_id: str, + chapter_index: int, +) -> None: + if not volume_id: + return + existing = [dict(item) for item in state.volume_memory_snapshots] + if any(str(item.get("volume_id") or "") == volume_id for item in existing): + return + snapshot = { + "snapshot_id": f"volume::{volume_id}::{chapter_index}", + "volume_id": volume_id, + "completed_at_chapter": int(chapter_index), + "rolling_recap": [dict(item) for item in state.rolling_recap[-3:]], + "active_unresolved_promise_ids": [promise.promise_id for promise in state.open_promises], + "character_memory_refs": sorted( + [ + character_id + for character_id, payload in (state.character_memory_runtime or {}).items() + if dict(payload or {}).get("adopted_memory_patches") + ] + ), + "storyline_checkpoint": dict(state.storyline_checkpoint or {}), + } + state.volume_memory_snapshots = existing + [snapshot] + state.volume_storyline_checkpoint = { + "last_completed_volume_id": volume_id, + "last_completed_at_chapter": int(chapter_index), + "snapshot_id": snapshot["snapshot_id"], + } + + +def _snapshot_completed_volume_if_needed(state: NarrativeState) -> None: + progression = dict((state.metadata or {}).get(LONGFORM_PROGRESS_KEY) or {}) + volume_id = str(progression.get("volume_id") or state.current_volume_id or "") + if not volume_id: + return + volume_chapter_index = int(progression.get("volume_chapter_index", 0) or 0) + volume_target_chapters = int(progression.get("volume_target_chapters", 0) or 0) + series_chapter_index = int(progression.get("series_chapter_index", state.chapter_index or 0) or 0) + series_target_chapters = int(progression.get("series_target_chapters", 0) or 0) + volume_completed = volume_target_chapters > 0 and volume_chapter_index >= volume_target_chapters + series_completed = series_target_chapters > 0 and series_chapter_index >= series_target_chapters + if volume_completed or series_completed: + _snapshot_volume_memory( + state, + volume_id=volume_id, + chapter_index=series_chapter_index or int(state.chapter_index or 0), + ) + + +def _snapshot_series_memory_if_needed(state: NarrativeState) -> None: + policy = _memory_compression_policy(state) + every_n_volumes = max(1, int(policy.get("series_snapshot_every_n_volumes", 2) or 2)) + snapshot_limit = max(1, int(policy.get("series_snapshot_limit", 3) or 3)) + volume_snapshots = [dict(item) for item in state.volume_memory_snapshots] + if not volume_snapshots: + return + latest_volume_ids = [ + str(item.get("volume_id") or "") + for item in volume_snapshots + if str(item.get("volume_id") or "") + ] + completed_volume_count = len(latest_volume_ids) + if completed_volume_count < every_n_volumes: + return + latest_completed_volume_id = latest_volume_ids[-1] + existing = [dict(item) for item in state.series_memory_snapshots] + if any(str(item.get("latest_completed_volume_id") or "") == latest_completed_volume_id for item in existing): + return + if completed_volume_count % every_n_volumes != 0 and not bool((state.metadata or {}).get("series_terminal_ready")): + return + chunk = volume_snapshots[-every_n_volumes:] + chapter_index = int(chunk[-1].get("completed_at_chapter", state.chapter_index) or state.chapter_index or 0) + unresolved_ids = sorted( + { + str(promise_id) + for item in chunk + for promise_id in item.get("active_unresolved_promise_ids", []) + if str(promise_id) + } + ) + character_refs = sorted( + { + str(character_id) + for item in chunk + for character_id in item.get("character_memory_refs", []) + if str(character_id) + } + ) + snapshot = { + "snapshot_id": f"series::{state.current_series_id or state.world_id}::{latest_completed_volume_id}::{chapter_index}", + "completed_volume_ids": [str(item.get("volume_id") or "") for item in chunk if str(item.get("volume_id") or "")], + "latest_completed_volume_id": latest_completed_volume_id, + "completed_at_chapter": chapter_index, + "volume_snapshot_refs": [str(item.get("snapshot_id") or "") for item in chunk if str(item.get("snapshot_id") or "")], + "distilled_recap": [ + str(((item.get("rolling_recap") or [{}])[-1] or {}).get("summary") or "") + for item in chunk + if ((item.get("rolling_recap") or [{}])[-1] or {}).get("summary") + ][:every_n_volumes], + "active_unresolved_promise_ids": unresolved_ids, + "character_memory_refs": character_refs, + "storyline_checkpoint": dict(state.storyline_checkpoint or {}), + } + state.series_memory_snapshots = (existing + [snapshot])[-snapshot_limit:] + + +def _prune_series_archive_memory_if_needed(state: NarrativeState) -> None: + policy = _memory_compression_policy(state) + retention_limit = max(1, int(policy.get("archive_retention_limit", 160) or 160)) + prune_margin = max(0, int(policy.get("series_archive_prune_margin_chapters", 40) or 40)) + archive = [dict(item) for item in state.archive_memory] + latest_series_snapshot = dict((state.series_memory_snapshots or [{}])[-1] or {}) + if not latest_series_snapshot and len(archive) <= retention_limit: + return + cutoff = max(0, int(latest_series_snapshot.get("completed_at_chapter", 0) or 0) - prune_margin) + retained = [ + item + for item in archive + if cutoff <= 0 + or int(item.get("last_referenced_chapter", 0) or 0) > cutoff + or float(item.get("importance", 0.0) or 0.0) >= 0.85 + or str(item.get("resolution_status", "") or "") == "active" + ] + if len(retained) > retention_limit: + retained = sorted( + retained, + key=lambda item: ( + float(item.get("importance", 0.0) or 0.0), + int(item.get("last_referenced_chapter", 0) or 0), + ), + reverse=True, + )[:retention_limit] + state.archive_memory = retained + + +def _prune_series_state_history_if_needed(state: NarrativeState) -> None: + policy = _memory_compression_policy(state) + timeline_limit = max(1, int(policy.get("timeline_retention_limit", 240) or 240)) + continuation_fact_limit = max(1, int(policy.get("continuation_fact_retention_limit", 120) or 120)) + continuation_visit_limit = max(1, int(policy.get("continuation_visit_retention_limit", 120) or 120)) + if len(state.timeline) > timeline_limit: + state.timeline = list(state.timeline[-timeline_limit:]) + + sticky_facts = [fact for fact in state.world_facts if not str(fact).startswith("continuation::")] + continuation_facts = [fact for fact in state.world_facts if str(fact).startswith("continuation::")] + if len(continuation_facts) > continuation_fact_limit: + continuation_facts = continuation_facts[-continuation_fact_limit:] + state.world_facts = sticky_facts + continuation_facts + + sticky_event_ids = [event_id for event_id in state.visited_event_ids if "__continuation__" not in str(event_id)] + continuation_event_ids = [event_id for event_id in state.visited_event_ids if "__continuation__" in str(event_id)] + if len(continuation_event_ids) > continuation_visit_limit: + continuation_event_ids = continuation_event_ids[-continuation_visit_limit:] + state.visited_event_ids = sticky_event_ids + continuation_event_ids + + +def _update_series_ending_checkpoint(state: NarrativeState) -> None: + progression = dict((state.metadata or {}).get(LONGFORM_PROGRESS_KEY) or {}) + target_chapters = int(progression.get("series_target_chapters", 0) or 0) + current_chapters = int(progression.get("series_chapter_index", state.chapter_index or 0) or state.chapter_index or 0) + if target_chapters <= 0: + return + policy = _memory_compression_policy(state) + activation_window = max(10, int(policy.get("series_ending_activation_window_chapters", 30) or 30)) + min_completion_ratio = float(policy.get("series_terminal_min_completion_ratio", 0.96) or 0.96) + chapters_remaining = max(0, target_chapters - current_chapters) + completion_ratio = current_chapters / float(max(1, target_chapters)) + in_final_window = chapters_remaining <= activation_window + terminal_ready = bool( + completion_ratio >= min_completion_ratio + and in_final_window + and int(progression.get("volume_chapter_index", 0) or 0) >= int(progression.get("volume_target_chapters", 0) or 0) + ) + state.metadata["series_terminal_ready"] = terminal_ready + state.series_ending_checkpoint = { + "target_chapters": target_chapters, + "current_chapters": current_chapters, + "chapters_remaining": chapters_remaining, + "completion_ratio": round(completion_ratio, 3), + "activation_window_chapters": activation_window, + "terminal_min_completion_ratio": min_completion_ratio, + "status": "ready" if terminal_ready else ("final_window" if in_final_window else "early_locked"), + "terminal_ready": terminal_ready, + "current_volume_id": progression.get("volume_id") or state.current_volume_id, + "current_arc_id": progression.get("arc_id") or state.current_arc_id, + "series_id": progression.get("series_id") or state.current_series_id, + } + + +def _fallback_duty_type(state: NarrativeState, world: WorldBible) -> str: + duty_cycle = list((world.creator_controls.metadata or {}).get("longform_duty_cycle", [])) + if duty_cycle: + duty_type = duty_cycle[(max(0, state.chapter_index)) % len(duty_cycle)] + else: + duty_type = { + "setup": "advance_plot", + "early_rising": "advance_relationship", + "midpoint": "expand_world", + "crisis": "resolve_promise", + "climax": "deliver_climax", + "aftermath": "pace_breath", + }.get(state.story_phase, "advance_plot") + if duty_type not in LONGFORM_DUTY_TYPES: + duty_type = "advance_plot" + return duty_type + + +def _select_volume_for_chapter(volume_plans: List[Dict[str, Any]], chapter_number: int) -> tuple[Dict[str, Any], int]: + cumulative = 0 + ordered_volumes = sorted(volume_plans, key=lambda item: int(item.get("order", 0))) + current_volume = ordered_volumes[-1] + current_index = chapter_number + for volume in ordered_volumes: + target_chapters = max(1, int(volume.get("target_chapters", 1))) + if chapter_number <= cumulative + target_chapters: + current_volume = volume + current_index = chapter_number - cumulative + break + cumulative += target_chapters + return current_volume, current_index + + +def _select_arc_for_chapter( + arc_plans: List[Dict[str, Any]], + *, + volume_id: str, + chapter_number_in_volume: int, +) -> tuple[Optional[Dict[str, Any]], int]: + volume_arcs = sorted( + [dict(item) for item in arc_plans if item.get("volume_id") == volume_id], + key=lambda item: int(item.get("order", 0)), + ) + if not volume_arcs: + return None, chapter_number_in_volume + cumulative = 0 + current_arc = volume_arcs[-1] + current_index = chapter_number_in_volume + for arc in volume_arcs: + target_chapters = max(1, int(arc.get("target_chapters", 1))) + if chapter_number_in_volume <= cumulative + target_chapters: + current_arc = arc + current_index = chapter_number_in_volume - cumulative + break + cumulative += target_chapters + return current_arc, current_index + + +def _plan_promise_catalog(plan: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + catalog: Dict[str, Dict[str, Any]] = {} + series_plan = dict(plan.get("series_plan") or {}) + for item in list(series_plan.get("series_promises") or []): + promise_id = str(dict(item or {}).get("promise_id") or "") + if promise_id: + catalog[promise_id] = dict(item or {}) + for volume in list(plan.get("volume_plans") or []): + for item in list(dict(volume or {}).get("volume_promises") or []): + promise_id = str(dict(item or {}).get("promise_id") or "") + if promise_id: + catalog[promise_id] = dict(item or {}) + for arc in list(plan.get("arc_plans") or []): + for item in list(dict(arc or {}).get("arc_promises") or []): + promise_id = str(dict(item or {}).get("promise_id") or "") + if promise_id: + catalog[promise_id] = dict(item or {}) + return catalog + + +def _instantiate_plan_promise( + promise: Dict[str, Any], + *, + current_chapter: int, +) -> PromiseLedgerEntry: + source_level = str(promise.get("source_level") or "arc") + base_horizon = { + "series": 12, + "volume": 8, + "arc": 4, + }.get(source_level, 4) + configured_due = int(promise.get("due_by_chapter", 0) or 0) + due_by_turn = current_chapter + max(2, min(max(2, configured_due), base_horizon)) + return PromiseLedgerEntry( + promise_id=str(promise.get("promise_id") or f"promise::{current_chapter}"), + description=str(promise.get("description") or promise.get("label") or ""), + opened_at_turn=current_chapter, + due_by_turn=due_by_turn, + holders=[str(item) for item in list(promise.get("holders") or []) if str(item)], + fulfillment_modes=["truth", "choice", "confession"], + status="open", + stakes=str(promise.get("stakes") or "medium"), + tags=[source_level, "longform_plan", "runway_seed"], + ) + + +def _ensure_longform_promise_runway( + state: NarrativeState, + *, + chapter_task: Dict[str, Any], + plan: Dict[str, Any], + chapter_number: int, + total_target_chapters: int, +) -> None: + if total_target_chapters < 100: + return + if chapter_number >= max(1, int(total_target_chapters * 0.8)): + return + if bool(chapter_task.get("bridge_only")) and not list(chapter_task.get("promise_targets") or []): + return + open_ids = {str(promise.promise_id) for promise in state.open_promises if str(getattr(promise, "status", "")) == "open"} + closed_ids = {str(item) for item in list((state.metadata or {}).get("closed_promise_ids", []) or []) if str(item)} + catalog = _plan_promise_catalog(plan) + desired_targets = [ + str(item) + for item in list(chapter_task.get("promise_targets") or []) + if str(item) and str(item) in catalog + ] + queue: List[str] = [] + for promise_id in desired_targets: + if promise_id not in queue: + queue.append(promise_id) + if not queue: + for promise_id in catalog: + if promise_id not in queue: + queue.append(promise_id) + appended = False + for promise_id in queue: + if promise_id in open_ids or promise_id in closed_ids: + continue + state.open_promises.append(_instantiate_plan_promise(catalog.get(promise_id, {"promise_id": promise_id}), current_chapter=chapter_number)) + open_ids.add(promise_id) + appended = True + if len(open_ids) >= 2: + break + if appended: + state.metadata["longform_last_runway_refresh_chapter"] = chapter_number + + +def _fallback_task( + state: NarrativeState, + *, + chapter_number: int, + objective_prefix: str, + volume_id: Optional[str], + arc_id: Optional[str], + allow_terminal: bool, + notes: str, + world: WorldBible, +) -> Dict[str, Any]: + duty_type = _fallback_duty_type(state, world) + return { + "chapter_task_id": f"{arc_id or volume_id or state.world_id}::chapter_{chapter_number}", + "objective": f"{objective_prefix}{duty_type}", + "duty_type": duty_type, + "target_words": int(state.word_budget or DEFAULT_LONGFORM_WORD_BUDGET), + "reveal_budget": 1, + "promise_actions": ["maintain_continuity"], + "promise_targets": [], + "allow_terminal": allow_terminal, + "bridge_only": True, + "notes": notes, + } + + +def sync_longform_progression(state: NarrativeState, world: WorldBible) -> Dict[str, Any]: + plan = _longform_plan_from_state(state, world) + series_plan = dict(plan.get("series_plan") or {}) + volume_plans = [dict(item) for item in plan.get("volume_plans", [])] + arc_plans = [dict(item) for item in plan.get("arc_plans", [])] + chapter_budget_policy = dict(plan.get("chapter_budget_policy") or {}) + chapter_number = max(1, int(state.chapter_index or 0)) + if not series_plan or not volume_plans: + fallback_task = _fallback_task( + state, + chapter_number=chapter_number, + objective_prefix=f"{state.story_phase} 阶段默认执行 ", + volume_id=state.current_volume_id, + arc_id=state.current_arc_id, + allow_terminal=False, + notes="auto_fallback_longform_task", + world=world, + ) + state.current_chapter_task = dict(fallback_task) + state.metadata[LONGFORM_PROGRESS_KEY] = { + "series_chapter_index": chapter_number, + "used_fallback": True, + } + return dict(state.metadata[LONGFORM_PROGRESS_KEY]) + + total_target_chapters = max(1, int(series_plan.get("total_chapter_target", len(volume_plans)))) + if chapter_budget_policy: + state.word_budget = int(chapter_budget_policy.get("default_target_words") or state.word_budget or DEFAULT_LONGFORM_WORD_BUDGET) + previous_volume_id = state.current_volume_id + previous_arc_id = state.current_arc_id + current_volume, chapter_number_in_volume = _select_volume_for_chapter(volume_plans, chapter_number) + current_arc, chapter_number_in_arc = _select_arc_for_chapter( + arc_plans, + volume_id=str(current_volume.get("volume_id", "")), + chapter_number_in_volume=chapter_number_in_volume, + ) + is_final_chapter = chapter_number >= total_target_chapters + chapter_tasks = list((current_arc or {}).get("chapter_tasks", [])) + if chapter_tasks: + template = dict(chapter_tasks[(max(0, chapter_number_in_arc - 1)) % len(chapter_tasks)]) + chapter_task = { + "chapter_task_id": str(template.get("chapter_task_id") or f"{(current_arc or {}).get('arc_id') or current_volume.get('volume_id')}::chapter_{chapter_number}"), + "objective": str(template.get("objective") or ""), + "duty_type": str(template.get("duty_type") or _fallback_duty_type(state, world)), + "target_words": int(template.get("target_words") or state.word_budget or DEFAULT_LONGFORM_WORD_BUDGET), + "reveal_budget": int(template.get("reveal_budget", chapter_budget_policy.get("default_reveal_budget", 1) if chapter_budget_policy else 1)), + "promise_actions": list(template.get("promise_actions", [])), + "promise_targets": list(template.get("promise_targets", [])), + "allow_terminal": bool(template.get("allow_terminal", False) and is_final_chapter), + "bridge_only": bool(template.get("bridge_only", False)), + "notes": str(template.get("notes") or "planned_longform_task"), + } + used_fallback = False + else: + chapter_task = _fallback_task( + state, + chapter_number=chapter_number, + objective_prefix=f"{current_arc.get('title', '当前弧线')} 默认执行 " if current_arc else "当前章节默认执行 ", + volume_id=str(current_volume.get("volume_id") or ""), + arc_id=str((current_arc or {}).get("arc_id") or ""), + allow_terminal=is_final_chapter, + notes="auto_fallback_arc_task", + world=world, + ) + used_fallback = True + state.current_series_id = str(series_plan.get("series_id") or state.current_series_id or "") + state.current_volume_id = str(current_volume.get("volume_id") or state.current_volume_id or "") + state.current_arc_id = str((current_arc or {}).get("arc_id") or state.current_arc_id or "") + state.current_chapter_task = dict(chapter_task) + state.word_budget = int(chapter_task.get("target_words") or state.word_budget or DEFAULT_LONGFORM_WORD_BUDGET) + _ensure_longform_promise_runway( + state, + chapter_task=chapter_task, + plan=plan, + chapter_number=chapter_number, + total_target_chapters=total_target_chapters, + ) + if previous_volume_id and previous_volume_id != state.current_volume_id: + _snapshot_volume_memory( + state, + volume_id=str(previous_volume_id), + chapter_index=max(0, chapter_number - 1), + ) + _record_replan_event( + state, + mode="strong", + reason="volume_boundary", + chapter_index=chapter_number, + volume_id=state.current_volume_id, + arc_id=state.current_arc_id, + ) + elif previous_arc_id and previous_arc_id != state.current_arc_id: + _record_replan_event( + state, + mode="soft", + reason="arc_boundary", + chapter_index=chapter_number, + volume_id=state.current_volume_id, + arc_id=state.current_arc_id, + ) + progression = { + "series_id": state.current_series_id, + "series_chapter_index": chapter_number, + "series_target_chapters": total_target_chapters, + "volume_id": state.current_volume_id, + "volume_order": int(current_volume.get("order", 1)), + "volume_title": str(current_volume.get("title") or ""), + "volume_chapter_index": chapter_number_in_volume, + "volume_target_chapters": int(current_volume.get("target_chapters", 1)), + "arc_id": state.current_arc_id, + "arc_order": int((current_arc or {}).get("order", 1)), + "arc_title": str((current_arc or {}).get("title") or ""), + "arc_chapter_index": chapter_number_in_arc, + "arc_target_chapters": int((current_arc or {}).get("target_chapters", 1)), + "task_sequence_index": chapter_number_in_arc, + "is_final_chapter": is_final_chapter, + "used_fallback": used_fallback, + "chapter_task": dict(chapter_task), + } + state.metadata[LONGFORM_PROGRESS_KEY] = dict(progression) + return progression + + +def default_chapter_task(state: NarrativeState, world: WorldBible) -> Dict[str, Any]: + sync_longform_progression(state, world) + return dict(state.current_chapter_task or {}) + + +def build_longform_context_pack(state: NarrativeState) -> Dict[str, Any]: + def _rank(memory: Dict[str, Any]) -> tuple[float, int]: + return ( + float(memory.get("importance", 0.0)), + int(memory.get("last_referenced_chapter", 0)), + ) + + policy = _memory_compression_policy(state) + canonical = sorted([dict(item) for item in state.canonical_memory], key=_rank, reverse=True)[:8] + active_arc = sorted([dict(item) for item in state.active_arc_memory], key=_rank, reverse=True)[: int(policy.get("active_arc_memory_limit", 12) or 12)] + rolling_recap = sorted([dict(item) for item in state.rolling_recap], key=_rank, reverse=True)[: int(policy.get("rolling_recap_limit", 8) or 8)] + archive = sorted([dict(item) for item in state.archive_memory], key=_rank, reverse=True)[: int(policy.get("archive_retrieval_limit", 12) or 12)] + promise_ledger = [promise.to_dict() for promise in state.open_promises] + interactive_contracts = _interactive_contracts_from_state(state) + volume_context_window = max(1, int(policy.get("volume_context_window", 2) or 2)) + series_snapshot_limit = max(1, int(policy.get("series_snapshot_limit", 3) or 3)) + return { + "current_series_id": state.current_series_id, + "current_volume_id": state.current_volume_id, + "current_arc_id": state.current_arc_id, + "current_chapter_task": dict(state.current_chapter_task or {}), + "progression": dict((state.metadata or {}).get(LONGFORM_PROGRESS_KEY) or {}), + "word_budget": int(state.word_budget or DEFAULT_LONGFORM_WORD_BUDGET), + "canonical_memory": canonical, + "active_arc_memory": active_arc, + "promise_ledger": promise_ledger, + "rolling_recap": rolling_recap, + "archive_memory": archive, + "volume_memory_snapshots": [dict(item) for item in state.volume_memory_snapshots[-volume_context_window:]], + "series_memory_snapshots": [dict(item) for item in state.series_memory_snapshots[-series_snapshot_limit:]], + "steering_ledger": [dict(item) for item in state.steering_ledger[-8:]], + "storyline_checkpoint": dict(state.storyline_checkpoint or {}), + "volume_storyline_checkpoint": dict(state.volume_storyline_checkpoint or {}), + "series_ending_checkpoint": dict(state.series_ending_checkpoint or {}), + "character_memory_runtime": dict(state.character_memory_runtime or {}), + "replan_checkpoint": dict(state.replan_checkpoint or {}), + "replan_history": [dict(item) for item in state.replan_history[-12:]], + "replan_stability_metrics": dict(state.replan_stability_metrics or {}), + "series_storyline_contract": dict(interactive_contracts.get("series_storyline_contract") or {}), + "steering_guardrails": dict(interactive_contracts.get("steering_guardrails") or {}), + } + + +def longform_terminal_allowed(state: NarrativeState, chapter_task: Dict[str, Any], event: EventAtom) -> bool: + if not (state.current_series_id or state.current_volume_id or state.current_arc_id or chapter_task): + return True + plan = _longform_plan_from_state(state) + series_plan = dict(plan.get("series_plan") or {}) + total_target_chapters = int(series_plan.get("total_chapter_target", 0) or 0) + if bool(chapter_task.get("allow_terminal")): + if total_target_chapters and int(state.chapter_index or 0) < total_target_chapters: + return False + return True + if total_target_chapters and int(state.chapter_index or 0) < total_target_chapters: + return False + return bool((state.metadata or {}).get("series_terminal_ready")) + + +def evaluate_longform_gate( + *, + target_chapters: int, + completed_chapters: int, + pass_rate: float, + block_rate: float, + stop_reason: str = "", + completion_ratio: Optional[float] = None, + mid_arc_pass_rate: Optional[float] = None, + q09_incidence_rate: float = 0.0, + character_drift_rate: float, + promise_unresolved_rate: float, + arc_task_repeat_rate: float, + premature_ending_trigger_rate: float, + volume_climax_spacing_error: float, +) -> Dict[str, Any]: + applicable = int(target_chapters) >= 100 + resolved_completion_ratio = ( + float(completion_ratio) + if completion_ratio is not None + else (int(completed_chapters) / float(max(1, int(target_chapters)))) + ) + continuity_signal_ready = int(completed_chapters) >= int(LONGFORM_100_GATE_THRESHOLDS["continuity_signal_chapters_min"]) + mid_arc_signal_ratio = float(LONGFORM_100_GATE_THRESHOLDS["mid_arc_signal_completion_ratio_min"]) + mid_arc_window_reached = resolved_completion_ratio >= mid_arc_signal_ratio + + def _check( + name: str, + *, + passed: bool, + actual: Any, + target: Any, + blocking: bool = True, + deferred: bool = False, + reason: Optional[str] = None, + ) -> Dict[str, Any]: + return { + "name": name, + "passed": passed, + "actual": actual, + "target": target, + "blocking": blocking, + "deferred": deferred, + "reason": reason, + } + + checks = [ + _check( + "completed_chapters", + passed=int(completed_chapters) >= int(target_chapters), + actual=int(completed_chapters), + target=int(target_chapters), + ), + _check( + "completion_ratio", + passed=resolved_completion_ratio >= 1.0, + actual=round(resolved_completion_ratio, 3), + target=1.0, + ), + _check( + "stop_reason", + passed=(int(completed_chapters) >= int(target_chapters)) or str(stop_reason or "") == "chapter_budget_reached", + actual=str(stop_reason or "unknown"), + target="chapter_budget_reached", + ), + _check( + "mid_arc_window_reached", + passed=mid_arc_window_reached, + actual=round(resolved_completion_ratio, 3), + target=mid_arc_signal_ratio, + ), + _check( + "pass_rate", + passed=float(pass_rate) >= float(LONGFORM_100_GATE_THRESHOLDS["pass_rate_min"]), + actual=round(float(pass_rate), 3), + target=float(LONGFORM_100_GATE_THRESHOLDS["pass_rate_min"]), + blocking=False, + ), + _check( + "block_rate", + passed=float(block_rate) <= float(LONGFORM_100_GATE_THRESHOLDS["block_rate_max"]), + actual=round(float(block_rate), 3), + target=float(LONGFORM_100_GATE_THRESHOLDS["block_rate_max"]), + blocking=False, + ), + _check( + "q09_incidence_rate", + passed=float(q09_incidence_rate) <= float(LONGFORM_100_GATE_THRESHOLDS["q09_incidence_rate_max"]), + actual=round(float(q09_incidence_rate), 3), + target=float(LONGFORM_100_GATE_THRESHOLDS["q09_incidence_rate_max"]), + blocking=False, + ), + _check( + "mid_arc_pass_rate", + passed=(float(mid_arc_pass_rate or 0.0) >= float(LONGFORM_100_GATE_THRESHOLDS["mid_arc_pass_rate_min"])) if mid_arc_window_reached else True, + actual=(round(float(mid_arc_pass_rate or 0.0), 3) if mid_arc_window_reached else None), + target=float(LONGFORM_100_GATE_THRESHOLDS["mid_arc_pass_rate_min"]), + blocking=False, + deferred=not mid_arc_window_reached, + reason=None if mid_arc_window_reached else "mid_arc_window_not_reached", + ), + _check( + "character_drift_rate", + passed=(float(character_drift_rate) <= float(LONGFORM_100_GATE_THRESHOLDS["character_drift_rate_max"])) if continuity_signal_ready else True, + actual=(round(float(character_drift_rate), 3) if continuity_signal_ready else None), + target=float(LONGFORM_100_GATE_THRESHOLDS["character_drift_rate_max"]), + blocking=False, + deferred=not continuity_signal_ready, + reason=None if continuity_signal_ready else "continuity_signal_not_ready", + ), + _check( + "promise_unresolved_rate", + passed=(float(promise_unresolved_rate) <= float(LONGFORM_100_GATE_THRESHOLDS["promise_unresolved_rate_max"])) if continuity_signal_ready else True, + actual=(round(float(promise_unresolved_rate), 3) if continuity_signal_ready else None), + target=float(LONGFORM_100_GATE_THRESHOLDS["promise_unresolved_rate_max"]), + blocking=False, + deferred=not continuity_signal_ready, + reason=None if continuity_signal_ready else "continuity_signal_not_ready", + ), + _check( + "arc_task_repeat_rate", + passed=(float(arc_task_repeat_rate) <= float(LONGFORM_100_GATE_THRESHOLDS["arc_task_repeat_rate_max"])) if continuity_signal_ready else True, + actual=(round(float(arc_task_repeat_rate), 3) if continuity_signal_ready else None), + target=float(LONGFORM_100_GATE_THRESHOLDS["arc_task_repeat_rate_max"]), + blocking=False, + deferred=not continuity_signal_ready, + reason=None if continuity_signal_ready else "continuity_signal_not_ready", + ), + _check( + "premature_ending_trigger_rate", + passed=float(premature_ending_trigger_rate) <= float(LONGFORM_100_GATE_THRESHOLDS["premature_ending_trigger_rate_max"]), + actual=round(float(premature_ending_trigger_rate), 3), + target=float(LONGFORM_100_GATE_THRESHOLDS["premature_ending_trigger_rate_max"]), + blocking=False, + ), + _check( + "volume_climax_spacing_error", + passed=float(volume_climax_spacing_error) <= float(LONGFORM_100_GATE_THRESHOLDS["volume_climax_spacing_error_max"]), + actual=round(float(volume_climax_spacing_error), 3), + target=float(LONGFORM_100_GATE_THRESHOLDS["volume_climax_spacing_error_max"]), + blocking=False, + ), + ] + failed_checks = [item["name"] for item in checks if item["blocking"] and not item["passed"]] + warning_checks = [item["name"] for item in checks if (not item["blocking"]) and (not item["passed"]) and (not item["deferred"])] + passed = applicable and not failed_checks + return { + "mode": "longform_100", + "applicable": applicable, + "passed": passed, + "status": "pass" if passed else ("block" if applicable else "not_applicable"), + "failed_checks": failed_checks, + "warning_checks": warning_checks, + "checks": checks, + "target_chapters": int(target_chapters), + "calibrated_thresholds": dict(LONGFORM_100_GATE_THRESHOLDS), + } + + +def calibrate_longform_thresholds(worlds: List[Dict[str, Any]]) -> Dict[str, Any]: + def _quantiles(values: List[float]) -> Dict[str, float]: + ordered = sorted(float(value) for value in values) + if not ordered: + return {"min": 0.0, "p50": 0.0, "p75": 0.0, "max": 0.0} + def _pick(ratio: float) -> float: + index = min(len(ordered) - 1, max(0, int(round((len(ordered) - 1) * ratio)))) + return round(ordered[index], 3) + return { + "min": round(ordered[0], 3), + "p50": _pick(0.5), + "p75": _pick(0.75), + "max": round(ordered[-1], 3), + } + + metrics = { + "completion_ratio": _quantiles([float(item.get("completion_ratio", 0.0) or 0.0) for item in worlds]), + "mid_arc_pass_rate": _quantiles([float(item.get("mid_arc_pass_rate", 0.0) or 0.0) for item in worlds]), + "q09_incidence_rate": _quantiles([float(item.get("q09_incidence_rate", 0.0) or 0.0) for item in worlds]), + "character_drift_rate": _quantiles([float(item.get("character_drift_rate", 0.0) or 0.0) for item in worlds]), + "promise_unresolved_rate": _quantiles([float(item.get("promise_unresolved_rate", 0.0) or 0.0) for item in worlds]), + "arc_task_repeat_rate": _quantiles([float(item.get("arc_task_repeat_rate", 0.0) or 0.0) for item in worlds]), + } + notes = [] + if metrics["completion_ratio"]["max"] < float(LONGFORM_100_GATE_THRESHOLDS["mid_arc_signal_completion_ratio_min"]): + notes.append("route_survival_failure_dominates_before_mid_arc") + if metrics["q09_incidence_rate"]["max"] <= float(LONGFORM_100_GATE_THRESHOLDS["q09_incidence_rate_max"]): + notes.append("q09_not_primary_blocker_in_current_baseline") + if metrics["arc_task_repeat_rate"]["p75"] > float(LONGFORM_100_GATE_THRESHOLDS["arc_task_repeat_rate_max"]): + notes.append("arc_task_repeat_is_high_but_secondary_until_routes_last_longer") + return { + "observed_metrics": metrics, + "recommended_thresholds": dict(LONGFORM_100_GATE_THRESHOLDS), + "notes": notes, + } + + +def archive_longform_chapter( + state: NarrativeState, + *, + chapter_plan: ChapterPlan, + chosen_event: EventAtom, + rendered_body: str, +) -> NarrativeState: + chapter_number = int(state.chapter_index or 0) + policy = _memory_compression_policy(state) + chapter_task = dict(chapter_plan.chapter_task or {}) + duty_type = str(chapter_task.get("duty_type") or "") + if duty_type: + recent_duty_types = [str(item) for item in list((state.metadata or {}).get("recent_duty_types") or []) if str(item)] + recent_duty_types.append(duty_type) + state.metadata["recent_duty_types"] = recent_duty_types[-4:] + chapter_task_id = str(chapter_task.get("chapter_task_id") or "") + if chapter_task_id: + recent_task_ids = [str(item) for item in list((state.metadata or {}).get("recent_chapter_task_ids") or []) if str(item)] + recent_task_ids.append(chapter_task_id) + state.metadata["recent_chapter_task_ids"] = recent_task_ids[-4:] + summary = (rendered_body or "").strip().replace("\n", " ") + if len(summary) > 240: + summary = summary[:237] + "..." + memory_unit = { + "memory_id": f"recap::{state.world_id}::{chapter_number}", + "memory_type": "chapter_recap", + "scope": state.current_arc_id or state.current_volume_id or state.current_series_id or "chapter", + "entity_refs": { + "character": list(chosen_event.actors or []), + "arc": [state.current_arc_id] if state.current_arc_id else [], + "volume": [state.current_volume_id] if state.current_volume_id else [], + }, + "summary": summary or chosen_event.summary or chosen_event.title, + "importance": 0.6, + "created_chapter": chapter_number, + "last_referenced_chapter": chapter_number, + "resolution_status": "active", + } + recap_limit = int(policy.get("rolling_recap_limit", ROLLING_RECAP_LIMIT) or ROLLING_RECAP_LIMIT) + state.rolling_recap = [dict(item) for item in state.rolling_recap] + [memory_unit] + if len(state.rolling_recap) > recap_limit: + overflow = state.rolling_recap[:-recap_limit] + state.archive_memory = [dict(item) for item in state.archive_memory] + overflow + state.rolling_recap = state.rolling_recap[-recap_limit:] + state.active_arc_memory = [dict(item) for item in state.active_arc_memory if item.get("memory_id") != memory_unit["memory_id"]] + state.active_arc_memory.append( + { + **memory_unit, + "memory_id": f"active::{state.world_id}::{chapter_number}", + "memory_type": "arc_delta", + "importance": 0.75, + } + ) + active_limit = int(policy.get("active_arc_memory_limit", 12) or 12) + if len(state.active_arc_memory) > active_limit: + overflow = state.active_arc_memory[:-active_limit] + state.archive_memory = [dict(item) for item in state.archive_memory] + overflow + state.active_arc_memory = state.active_arc_memory[-active_limit:] + adopted_any = False + for character_id, payload in list((state.character_memory_runtime or {}).items()): + runtime_entry = dict(payload or {}) + pending = [dict(item) for item in runtime_entry.get("pending_memory_patches", [])] + adopted = [dict(item) for item in runtime_entry.get("adopted_memory_patches", [])] + next_pending = [] + for patch in pending: + if character_id in (chosen_event.actors or []): + adopted.append({**patch, "status": "adopted", "adopted_at_chapter": chapter_number}) + adopted_any = True + else: + next_pending.append(patch) + runtime_entry["pending_memory_patches"] = next_pending[-10:] + runtime_entry["adopted_memory_patches"] = adopted[-10:] + state.character_memory_runtime[character_id] = runtime_entry + if adopted_any: + state.storyline_checkpoint = { + **dict(state.storyline_checkpoint or {}), + "memory_patch_adopted_at_chapter": chapter_number, + } + # Snapshot the just-finished volume on its terminal chapter as well as on + # later boundary transitions so the final volume is not missed. + _snapshot_completed_volume_if_needed(state) + _update_series_ending_checkpoint(state) + _snapshot_series_memory_if_needed(state) + _prune_series_archive_memory_if_needed(state) + _prune_series_state_history_if_needed(state) + snapshot_every = int(policy.get("volume_snapshot_every_n_chapters", 1) or 1) + if state.current_volume_id and snapshot_every > 0 and chapter_number % snapshot_every == 0: + state.volume_storyline_checkpoint = { + **dict(state.volume_storyline_checkpoint or {}), + "last_seen_volume_id": state.current_volume_id, + "last_seen_at_chapter": chapter_number, + } + return state diff --git a/src/narrativeos/models.py b/src/narrativeos/models.py index d297f9d..f28091f 100644 --- a/src/narrativeos/models.py +++ b/src/narrativeos/models.py @@ -8,6 +8,14 @@ RATINGS_ORDER = {"G": 0, "PG": 1, "PG13": 2, "R": 3} STORY_PHASES = ("setup", "early_rising", "midpoint", "crisis", "climax", "aftermath") +LONGFORM_DUTY_TYPES = ( + "advance_plot", + "advance_relationship", + "resolve_promise", + "expand_world", + "pace_breath", + "deliver_climax", +) def _deepcopy_dataclass(instance: Any) -> Dict[str, Any]: @@ -487,6 +495,25 @@ class NarrativeState: visited_event_ids: List[str] route_fingerprint: List[str] rating_ceiling: str + current_series_id: Optional[str] = None + current_volume_id: Optional[str] = None + current_arc_id: Optional[str] = None + current_chapter_task: Dict[str, Any] = field(default_factory=dict) + word_budget: int = 2000 + canonical_memory: List[Dict[str, Any]] = field(default_factory=list) + active_arc_memory: List[Dict[str, Any]] = field(default_factory=list) + rolling_recap: List[Dict[str, Any]] = field(default_factory=list) + archive_memory: List[Dict[str, Any]] = field(default_factory=list) + volume_memory_snapshots: List[Dict[str, Any]] = field(default_factory=list) + series_memory_snapshots: List[Dict[str, Any]] = field(default_factory=list) + steering_ledger: List[Dict[str, Any]] = field(default_factory=list) + storyline_checkpoint: Dict[str, Any] = field(default_factory=dict) + volume_storyline_checkpoint: Dict[str, Any] = field(default_factory=dict) + series_ending_checkpoint: Dict[str, Any] = field(default_factory=dict) + character_memory_runtime: Dict[str, Any] = field(default_factory=dict) + replan_checkpoint: Dict[str, Any] = field(default_factory=dict) + replan_history: List[Dict[str, Any]] = field(default_factory=list) + replan_stability_metrics: Dict[str, Any] = field(default_factory=dict) metadata: Dict[str, Any] = field(default_factory=dict) @classmethod @@ -510,11 +537,30 @@ def from_dict(cls, data: Dict[str, Any]) -> "NarrativeState": payload.setdefault("fate_pressure", 0.0) payload.setdefault("karmic_weather", {}) payload.setdefault("unresolved_debts", []) + payload.setdefault("current_series_id", None) + payload.setdefault("current_volume_id", None) + payload.setdefault("current_arc_id", None) + payload.setdefault("current_chapter_task", {}) + payload.setdefault("word_budget", 2000) + payload.setdefault("canonical_memory", []) + payload.setdefault("active_arc_memory", []) + payload.setdefault("rolling_recap", []) + payload.setdefault("archive_memory", []) + payload.setdefault("volume_memory_snapshots", []) + payload.setdefault("series_memory_snapshots", []) + payload.setdefault("steering_ledger", []) + payload.setdefault("storyline_checkpoint", {}) + payload.setdefault("volume_storyline_checkpoint", {}) + payload.setdefault("series_ending_checkpoint", {}) + payload.setdefault("character_memory_runtime", {}) + payload.setdefault("replan_checkpoint", {}) + payload.setdefault("replan_history", []) + payload.setdefault("replan_stability_metrics", {}) payload.setdefault("metadata", {}) return cls(**payload) def to_dict(self) -> Dict[str, Any]: - return { + payload = { "state_id": self.state_id, "world_id": self.world_id, "turn_index": self.turn_index, @@ -538,6 +584,51 @@ def to_dict(self) -> Dict[str, Any]: "rating_ceiling": self.rating_ceiling, "metadata": dict(self.metadata), } + if self.current_series_id is not None: + payload["current_series_id"] = self.current_series_id + if self.current_volume_id is not None: + payload["current_volume_id"] = self.current_volume_id + if self.current_arc_id is not None: + payload["current_arc_id"] = self.current_arc_id + if self.current_chapter_task: + payload["current_chapter_task"] = dict(self.current_chapter_task) + if ( + self.word_budget != 2000 + or self.current_series_id is not None + or self.current_volume_id is not None + or self.current_arc_id is not None + or self.current_chapter_task + ): + payload["word_budget"] = self.word_budget + if self.canonical_memory: + payload["canonical_memory"] = [dict(item) for item in self.canonical_memory] + if self.active_arc_memory: + payload["active_arc_memory"] = [dict(item) for item in self.active_arc_memory] + if self.rolling_recap: + payload["rolling_recap"] = [dict(item) for item in self.rolling_recap] + if self.archive_memory: + payload["archive_memory"] = [dict(item) for item in self.archive_memory] + if self.volume_memory_snapshots: + payload["volume_memory_snapshots"] = [dict(item) for item in self.volume_memory_snapshots] + if self.series_memory_snapshots: + payload["series_memory_snapshots"] = [dict(item) for item in self.series_memory_snapshots] + if self.steering_ledger: + payload["steering_ledger"] = [dict(item) for item in self.steering_ledger] + if self.storyline_checkpoint: + payload["storyline_checkpoint"] = dict(self.storyline_checkpoint) + if self.volume_storyline_checkpoint: + payload["volume_storyline_checkpoint"] = dict(self.volume_storyline_checkpoint) + if self.series_ending_checkpoint: + payload["series_ending_checkpoint"] = dict(self.series_ending_checkpoint) + if self.character_memory_runtime: + payload["character_memory_runtime"] = dict(self.character_memory_runtime) + if self.replan_checkpoint: + payload["replan_checkpoint"] = dict(self.replan_checkpoint) + if self.replan_history: + payload["replan_history"] = [dict(item) for item in self.replan_history] + if self.replan_stability_metrics: + payload["replan_stability_metrics"] = dict(self.replan_stability_metrics) + return payload @dataclass @@ -706,6 +797,8 @@ class SceneRenderSpec: sensory_motifs: List[str] emotional_pivot: str ending_cadence: str + min_target_word_count: int = 0 + max_target_word_count: int = 0 must_include_beats: List[str] = field(default_factory=list) @classmethod @@ -714,6 +807,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "SceneRenderSpec": prose_mode=data["prose_mode"], viewpoint_character=data.get("viewpoint_character", ""), target_word_count=int(data.get("target_word_count", 900)), + min_target_word_count=int(data.get("min_target_word_count", 0) or 0), + max_target_word_count=int(data.get("max_target_word_count", 0) or 0), dialogue_density=float(data.get("dialogue_density", 0.35)), sensory_motifs=list(data.get("sensory_motifs", [])), emotional_pivot=data.get("emotional_pivot", ""), @@ -734,6 +829,8 @@ class ChapterPlan: beat_count: int ending_ready: bool selected_event_ids: List[str] + chapter_task: Dict[str, Any] = field(default_factory=dict) + chapter_task_execution_summary: Dict[str, Any] = field(default_factory=dict) @classmethod def from_dict(cls, data: Dict[str, Any]) -> "ChapterPlan": @@ -745,6 +842,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "ChapterPlan": beat_count=int(data.get("beat_count", 0)), ending_ready=bool(data.get("ending_ready", False)), selected_event_ids=list(data.get("selected_event_ids", [])), + chapter_task=dict(data.get("chapter_task", {})), + chapter_task_execution_summary=dict(data.get("chapter_task_execution_summary", {})), ) def to_dict(self) -> Dict[str, Any]: @@ -756,6 +855,8 @@ def to_dict(self) -> Dict[str, Any]: "beat_count": self.beat_count, "ending_ready": self.ending_ready, "selected_event_ids": list(self.selected_event_ids), + "chapter_task": dict(self.chapter_task), + "chapter_task_execution_summary": dict(self.chapter_task_execution_summary), } @@ -815,6 +916,7 @@ class NarrativeViewModel: choices: List[str] relationship_hints: List[str] can_continue: bool + choice_impacts: List[Dict[str, Any]] = field(default_factory=list) @classmethod def from_dict(cls, data: Dict[str, Any]) -> "NarrativeViewModel": @@ -827,6 +929,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "NarrativeViewModel": choices=list(data.get("choices", [])), relationship_hints=list(data.get("relationship_hints", [])), can_continue=bool(data.get("can_continue", True)), + choice_impacts=[dict(item) for item in data.get("choice_impacts", []) if isinstance(item, dict)], ) def to_dict(self) -> Dict[str, Any]: diff --git a/src/narrativeos/persistence/db.py b/src/narrativeos/persistence/db.py index 0da239b..95cfc05 100644 --- a/src/narrativeos/persistence/db.py +++ b/src/narrativeos/persistence/db.py @@ -1,9 +1,10 @@ from __future__ import annotations from datetime import datetime, timezone +import os from pathlib import Path -from sqlalchemy import JSON, Column, Float, Index, Integer, String, Text, create_engine +from sqlalchemy import JSON, Boolean, Column, Float, Index, Integer, String, Text, UniqueConstraint, create_engine, event, inspect from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, declarative_base, sessionmaker @@ -269,6 +270,263 @@ class AuthorNotificationPreferenceRow(PlatformBase): updated_at = Column(String, nullable=False, default=utcnow_iso) +class ShowcaseWorkLikeRow(PlatformBase): + __tablename__ = "showcase_work_likes" + __table_args__ = ( + UniqueConstraint("world_id", "account_id", name="uq_showcase_work_likes_world_account"), + Index("idx_showcase_work_likes_world_created_at", "world_id", "created_at"), + Index("idx_showcase_work_likes_account_created_at", "account_id", "created_at"), + ) + + showcase_like_id = Column(String, primary_key=True) + world_id = Column(String, nullable=False, index=True) + world_version_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + actor_id = Column(String, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ShowcaseWorkCommentRow(PlatformBase): + __tablename__ = "showcase_work_comments" + __table_args__ = ( + Index("idx_showcase_work_comments_world_status_created_at", "world_id", "status", "created_at"), + Index("idx_showcase_work_comments_account_created_at", "account_id", "created_at"), + ) + + showcase_comment_id = Column(String, primary_key=True) + world_id = Column(String, nullable=False, index=True) + world_version_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + actor_id = Column(String, nullable=True) + author_name = Column(String, nullable=False) + content = Column(Text, nullable=False) + status = Column(String, nullable=False, default="published", index=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ShowcaseWorkTipRow(PlatformBase): + __tablename__ = "showcase_work_tips" + __table_args__ = ( + Index("idx_showcase_work_tips_world_created_at", "world_id", "created_at"), + Index("idx_showcase_work_tips_account_created_at", "account_id", "created_at"), + ) + + showcase_tip_id = Column(String, primary_key=True) + world_id = Column(String, nullable=False, index=True) + world_version_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + actor_id = Column(String, nullable=True) + amount = Column(Integer, nullable=False, default=0) + wallet_type = Column(String, nullable=False, default="story_credits") + balance_after = Column(Float, nullable=False, default=0.0) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class StorySessionBookmarkRow(PlatformBase): + __tablename__ = "story_session_bookmarks" + __table_args__ = ( + UniqueConstraint("session_id", "account_id", "node_id", name="uq_story_session_bookmarks_session_account_node"), + Index("idx_story_session_bookmarks_session_created_at", "session_id", "created_at"), + Index("idx_story_session_bookmarks_account_created_at", "account_id", "created_at"), + ) + + bookmark_id = Column(String, primary_key=True) + session_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + node_id = Column(String, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class StorySessionShareTokenRow(PlatformBase): + __tablename__ = "story_session_share_tokens" + __table_args__ = ( + Index("idx_story_session_share_tokens_token_created_at", "share_token", "created_at"), + Index("idx_story_session_share_tokens_session_created_at", "session_id", "created_at"), + Index("idx_story_session_share_tokens_account_created_at", "account_id", "created_at"), + Index( + "idx_story_session_share_tokens_session_account_node_status", + "session_id", + "account_id", + "node_id", + "status", + ), + ) + + share_token = Column(String, primary_key=True) + session_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + node_id = Column(String, nullable=False) + sharer_name = Column(String, nullable=False) + status = Column(String, nullable=False, default="active") + expires_at = Column(String, nullable=True) + revoked_at = Column(String, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class SoulProfilePreferenceRow(PlatformBase): + __tablename__ = "soul_profile_preferences" + __table_args__ = ( + Index("idx_soul_profile_preferences_account_updated_at", "account_id", "updated_at"), + ) + + actor_id = Column(String, primary_key=True) + account_id = Column(String, nullable=True, index=True) + genres_json = Column(JSON, nullable=False, default=list) + styles_json = Column(JSON, nullable=False, default=list) + privacy_mode = Column(String, nullable=False, default="followers") + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class LibraryWorkFavoriteRow(PlatformBase): + __tablename__ = "library_work_favorites" + __table_args__ = ( + UniqueConstraint("account_id", "work_id", name="uq_library_work_favorites_account_work"), + Index("idx_library_work_favorites_account_created_at", "account_id", "created_at"), + Index("idx_library_work_favorites_work_created_at", "work_id", "created_at"), + ) + + favorite_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, index=True) + work_id = Column(String, nullable=False, index=True) + work_kind = Column(String, nullable=False) + title_snapshot = Column(String, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class LibraryFollowRow(PlatformBase): + __tablename__ = "library_follows" + __table_args__ = ( + UniqueConstraint("account_id", "target_type", "target_id", name="uq_library_follows_account_target"), + Index("idx_library_follows_account_created_at", "account_id", "created_at"), + Index("idx_library_follows_target_created_at", "target_type", "target_id", "created_at"), + ) + + follow_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, index=True) + target_type = Column(String, nullable=False, index=True) + target_id = Column(String, nullable=False, index=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class LibraryStatsCubeRow(PlatformBase): + __tablename__ = "library_stats_cubes" + __table_args__ = ( + UniqueConstraint("account_id", name="uq_library_stats_cubes_account"), + Index("idx_library_stats_cubes_account_updated_at", "account_id", "updated_at"), + Index("idx_library_stats_cubes_source_updated_at", "source_updated_at"), + Index("idx_library_stats_cubes_invalidated_at", "invalidated_at"), + ) + + library_stats_cube_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, index=True) + semantic_version = Column(String, nullable=False, default="library_stats_semantic/v2") + snapshot_payload_json = Column(JSON, nullable=False, default=dict) + source_breakdown_json = Column(JSON, nullable=False, default=dict) + source_updated_at = Column(String, nullable=False, default=utcnow_iso) + invalidated_at = Column(String, nullable=True) + last_invalidated_event_name = Column(String, nullable=True) + last_invalidated_event_at = Column(String, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ShowcaseWorkViewRow(PlatformBase): + __tablename__ = "showcase_work_views" + __table_args__ = ( + UniqueConstraint("world_id", "viewer_key", "event_type", name="uq_showcase_work_views_world_viewer_event"), + Index("idx_showcase_work_views_world_event_created_at", "world_id", "event_type", "created_at"), + Index("idx_showcase_work_views_account_event_created_at", "account_id", "event_type", "created_at"), + ) + + showcase_view_id = Column(String, primary_key=True) + world_id = Column(String, nullable=False, index=True) + world_version_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=True, index=True) + viewer_key = Column(String, nullable=False, index=True) + event_type = Column(String, nullable=False, default="view", index=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class GeneratedMediaAssetRow(PlatformBase): + __tablename__ = "generated_media_assets" + __table_args__ = ( + Index( + "idx_generated_media_assets_owner_kind_status_updated_at", + "owner_scope", + "owner_id", + "asset_kind", + "generation_status", + "updated_at", + ), + Index( + "idx_generated_media_assets_world_kind_status_updated_at", + "world_version_id", + "asset_kind", + "generation_status", + "updated_at", + ), + Index( + "idx_generated_media_assets_owner_fingerprint", + "owner_scope", + "owner_id", + "asset_kind", + "source_fingerprint", + ), + ) + + asset_id = Column(String, primary_key=True) + asset_kind = Column(String, nullable=False, index=True) + owner_scope = Column(String, nullable=False, index=True) + owner_id = Column(String, nullable=False, index=True) + world_id = Column(String, nullable=True, index=True) + world_version_id = Column(String, nullable=True, index=True) + session_id = Column(String, nullable=True, index=True) + chapter_index = Column(Integer, nullable=True) + reader_id = Column(String, nullable=True, index=True) + storage_bucket = Column(String, nullable=True) + storage_key = Column(String, nullable=True) + mime_type = Column(String, nullable=True) + width = Column(Integer, nullable=True) + height = Column(Integer, nullable=True) + visibility = Column(String, nullable=False, default="private", index=True) + generation_status = Column(String, nullable=False, default="queued", index=True) + model_name = Column(String, nullable=True) + prompt_version = Column(String, nullable=True) + source_fingerprint = Column(String, nullable=True, index=True) + prompt_trace_json = Column(JSON, nullable=True) + error = Column(Text, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class AuthorProjectGraphRow(PlatformBase): + __tablename__ = "author_project_graphs" + __table_args__ = ( + Index("idx_author_project_graphs_account_updated_at", "account_id", "updated_at"), + Index("idx_author_project_graphs_world_version_updated_at", "world_version_id", "updated_at"), + ) + + project_id = Column(String, primary_key=True) + world_version_id = Column(String, nullable=False, unique=True, index=True) + account_id = Column(String, nullable=False, index=True) + engine = Column(String, nullable=False, default="balanced") + enabled_rule_ids_json = Column(JSON, nullable=False, default=list) + nodes_json = Column(JSON, nullable=False, default=list) + connections_json = Column(JSON, nullable=False, default=list) + metadata_json = Column(JSON, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + class AuthIdentityRow(PlatformBase): __tablename__ = "auth_identities" @@ -283,6 +541,72 @@ class AuthIdentityRow(PlatformBase): updated_at = Column(String, nullable=False, default=utcnow_iso) +class AuthorWorkRow(PlatformBase): + __tablename__ = "author_works" + __table_args__ = ( + Index("idx_author_works_account_status_updated_at", "account_id", "status", "updated_at"), + Index("idx_author_works_world_version_updated_at", "world_version_id", "updated_at"), + Index("idx_author_works_root_work_updated_at", "root_work_id", "updated_at"), + ) + + work_id = Column(String, primary_key=True) + world_version_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + title = Column(String, nullable=False) + status = Column(String, nullable=False, default="draft") + current_revision = Column(String, nullable=True) + chapter_count = Column(Integer, nullable=False, default=0) + target_chapter_count = Column(Integer, nullable=False, default=0) + branch_id = Column(String, nullable=True, index=True) + root_work_id = Column(String, nullable=True, index=True) + parent_work_id = Column(String, nullable=True, index=True) + branch_name = Column(String, nullable=True) + branch_kind = Column(String, nullable=True) + branch_origin_label = Column(Text, nullable=True) + fork_after_chapter_index = Column(Integer, nullable=True) + is_active_line = Column(Integer, nullable=False, default=0) + narrative_state_json = Column(JSON, nullable=True) + diagnostics_summary_json = Column(JSON, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class AuthorWorkChapterRow(PlatformBase): + __tablename__ = "author_work_chapters" + __table_args__ = ( + Index("idx_author_work_chapters_work_chapter", "work_id", "chapter_index"), + ) + + chapter_record_id = Column(String, primary_key=True) + work_id = Column(String, nullable=False, index=True) + chapter_index = Column(Integer, nullable=False) + chapter_title = Column(String, nullable=False) + body = Column(Text, nullable=False) + status = Column(String, nullable=False, default="generated") + source_type = Column(String, nullable=False, default="generated") + summary = Column(Text, nullable=True) + diagnostic_summary_json = Column(JSON, nullable=True) + chapter_task_json = Column(JSON, nullable=True) + choices_json = Column(JSON, nullable=True) + state_snapshot_json = Column(JSON, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class AuthorWorkRevisionRow(PlatformBase): + __tablename__ = "author_work_revisions" + __table_args__ = ( + Index("idx_author_work_revisions_work_created_at", "work_id", "created_at"), + ) + + revision_id = Column(String, primary_key=True) + work_id = Column(String, nullable=False, index=True) + revision_type = Column(String, nullable=False) + summary = Column(Text, nullable=True) + snapshot_json = Column(JSON, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + + class AuthTokenRow(PlatformBase): __tablename__ = "auth_tokens" @@ -297,6 +621,71 @@ class AuthTokenRow(PlatformBase): last_used_at = Column(String, nullable=True) +class AuthIdentityProfileRow(PlatformBase): + __tablename__ = "auth_identity_profiles" + + actor_id = Column(String, primary_key=True) + account_id = Column(String, nullable=True, index=True) + email_address = Column(String, nullable=True, index=True) + pending_email_address = Column(String, nullable=True, index=True) + avatar_url = Column(String, nullable=True) + email_verified = Column(String, nullable=False, default="false") + verification_required = Column(String, nullable=False, default="false") + verification_sent_at = Column(String, nullable=True) + verified_at = Column(String, nullable=True) + password_reset_sent_at = Column(String, nullable=True) + pending_email_change_requested_at = Column(String, nullable=True) + email_change_last_sent_at = Column(String, nullable=True) + ui_preferences_json = Column(JSON, nullable=True) + deactivated_at = Column(String, nullable=True) + deactivated_by = Column(String, nullable=True) + deactivation_reason = Column(Text, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class AuthFlowTokenRow(PlatformBase): + __tablename__ = "auth_flow_tokens" + + flow_token_id = Column(String, primary_key=True) + actor_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=True, index=True) + flow_type = Column(String, nullable=False, index=True) + token_hash = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="active") + payload_json = Column(JSON, nullable=True) + expires_at = Column(String, nullable=True) + consumed_at = Column(String, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class AuthDeliveryAttemptRow(PlatformBase): + __tablename__ = "auth_delivery_attempts" + __table_args__ = ( + Index("idx_auth_delivery_attempts_actor_flow_created_at", "actor_id", "flow_type", "created_at"), + Index("idx_auth_delivery_attempts_recipient_created_at", "recipient_email", "created_at"), + Index("idx_auth_delivery_attempts_status_created_at", "status", "created_at"), + ) + + attempt_id = Column(String, primary_key=True) + actor_id = Column(String, nullable=True, index=True) + account_id = Column(String, nullable=True, index=True) + flow_type = Column(String, nullable=False, index=True) + provider = Column(String, nullable=False) + email_mode = Column(String, nullable=False) + sender_email = Column(String, nullable=True) + recipient_email = Column(String, nullable=False) + status = Column(String, nullable=False) + provider_message_id = Column(String, nullable=True, index=True) + error_code = Column(String, nullable=True, index=True) + error_reason = Column(Text, nullable=True) + retryable = Column(String, nullable=False, default="false") + metadata_json = Column(JSON, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + class BillingRetryAttemptRow(PlatformBase): __tablename__ = "billing_retry_attempts" @@ -319,7 +708,9 @@ class BillingCheckoutSessionRow(PlatformBase): checkout_session_id = Column(String, primary_key=True) account_id = Column(String, nullable=False, index=True) + checkout_kind = Column(String, nullable=False, default="subscription") tier_id = Column(String, nullable=False) + package_id = Column(String, nullable=True, index=True) provider = Column(String, nullable=False) provider_ref = Column(String, nullable=True, index=True) subscription_id = Column(String, nullable=True, index=True) @@ -327,6 +718,7 @@ class BillingCheckoutSessionRow(PlatformBase): checkout_url = Column(Text, nullable=True) idempotency_key = Column(String, nullable=False, index=True) expires_at = Column(String, nullable=True) + fulfilled_at = Column(String, nullable=True) created_at = Column(String, nullable=False, default=utcnow_iso) updated_at = Column(String, nullable=False, default=utcnow_iso) @@ -348,6 +740,34 @@ class BillingLifecycleEventRow(PlatformBase): processed_at = Column(String, nullable=True) +class ProviderSubscriptionRow(PlatformBase): + __tablename__ = "provider_subscriptions" + __table_args__ = ( + Index("idx_provider_subscriptions_account_status_updated_at", "account_id", "status", "updated_at"), + Index("idx_provider_subscriptions_provider_ref", "provider", "provider_ref"), + ) + + provider_subscription_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, index=True) + tier_id = Column(String, nullable=False) + provider = Column(String, nullable=False) + provider_ref = Column(String, nullable=True) + provider_customer_id = Column(String, nullable=True, index=True) + provider_checkout_session_id = Column(String, nullable=True, index=True) + provider_order_id = Column(String, nullable=True, index=True) + environment = Column(String, nullable=False, default="test") + verification_status = Column(String, nullable=False, default="pending") + last_verified_at = Column(String, nullable=True) + status = Column(String, nullable=False, default="trialing") + period_start = Column(String, nullable=True) + period_end = Column(String, nullable=True) + cancel_at_period_end = Column(String, nullable=True) + latest_event_id = Column(String, nullable=True, index=True) + payload_json = Column(JSON, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + class AnalyticsEventRow(PlatformBase): __tablename__ = "analytics_events" __table_args__ = ( @@ -365,8 +785,1509 @@ class AnalyticsEventRow(PlatformBase): occurred_at = Column(String, nullable=False, default=utcnow_iso) -def create_platform_engine(database_url: str): - return create_engine(database_url, future=True) +class OpsReviewItemRow(PlatformBase): + __tablename__ = "ops_review_items" + __table_args__ = ( + Index("idx_ops_review_items_queue_status_priority_updated_at", "queue", "status", "priority", "updated_at"), + Index("idx_ops_review_items_owner_status_updated_at", "owner_id", "status", "updated_at"), + Index("idx_ops_review_items_source_type_source_id", "source_type", "source_id"), + Index("idx_ops_review_items_account_queue_updated_at", "account_id", "queue", "updated_at"), + Index("idx_ops_review_items_world_queue_updated_at", "world_id", "queue", "updated_at"), + Index("idx_ops_review_items_world_version_queue_updated_at", "world_version_id", "queue", "updated_at"), + ) + + review_item_id = Column(String, primary_key=True) + source_type = Column(String, nullable=False, index=True) + source_id = Column(String, nullable=False, index=True) + queue = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="new") + severity = Column(String, nullable=False, default="medium") + priority = Column(Integer, nullable=False, default=100) + owner_id = Column(String, nullable=True, index=True) + reviewer_id = Column(String, nullable=True, index=True) + account_id = Column(String, nullable=True, index=True) + world_id = Column(String, nullable=True, index=True) + world_version_id = Column(String, nullable=True, index=True) + headline = Column(Text, nullable=False) + summary = Column(Text, nullable=True) + recommended_action = Column(String, nullable=True) + due_at = Column(String, nullable=True, index=True) + sla_bucket = Column(String, nullable=True, index=True) + allowed_actions_json = Column(JSON, nullable=True) + linked_entities_json = Column(JSON, nullable=True) + source_updated_at = Column(String, nullable=True) + last_synced_at = Column(String, nullable=False, default=utcnow_iso) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class QualityPolicyRow(PlatformBase): + __tablename__ = "quality_policies" + __table_args__ = ( + Index("idx_quality_policies_scenario_risk_updated_at", "scenario_id", "risk_tier", "updated_at"), + Index("idx_quality_policies_mode_updated_at", "mode", "updated_at"), + ) + + policy_id = Column(String, primary_key=True) + version = Column(String, nullable=False) + scenario_id = Column(String, nullable=False, index=True) + risk_tier = Column(String, nullable=False, index=True) + mode = Column(String, nullable=False, index=True) + rule_ids_json = Column(JSON, nullable=False) + policy_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class OpsConfigRow(PlatformBase): + __tablename__ = "ops_configs" + __table_args__ = ( + Index("idx_ops_configs_type_scope_updated_at", "config_type", "scope_key", "updated_at"), + Index("idx_ops_configs_status_updated_at", "status", "updated_at"), + ) + + ops_config_id = Column(String, primary_key=True) + config_type = Column(String, nullable=False, index=True) + scope_key = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="active", index=True) + config_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class QualityEventRow(PlatformBase): + __tablename__ = "quality_events" + __table_args__ = ( + Index("idx_quality_events_trace_created_at", "trace_id", "created_at"), + Index("idx_quality_events_surface_status_created_at", "source_surface", "status", "created_at"), + Index("idx_quality_events_world_created_at", "world_version_id", "created_at"), + Index("idx_quality_events_session_created_at", "session_id", "created_at"), + ) + + event_id = Column(String, primary_key=True) + trace_id = Column(String, nullable=False, index=True) + event_type = Column(String, nullable=False, index=True) + source_surface = Column(String, nullable=False, index=True) + status = Column(String, nullable=True, index=True) + world_version_id = Column(String, nullable=True, index=True) + session_id = Column(String, nullable=True, index=True) + source_ref_json = Column(JSON, nullable=False) + payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class ContentQualityScoreRow(PlatformBase): + __tablename__ = "content_quality_scores" + __table_args__ = ( + Index("idx_content_quality_scores_trace_created_at", "trace_id", "created_at"), + Index("idx_content_quality_scores_status_created_at", "status", "created_at"), + Index("idx_content_quality_scores_world_created_at", "world_version_id", "created_at"), + Index("idx_content_quality_scores_session_created_at", "session_id", "created_at"), + ) + + score_id = Column(String, primary_key=True) + trace_id = Column(String, nullable=True, index=True) + source_surface = Column(String, nullable=False, index=True) + status = Column(String, nullable=True, index=True) + world_version_id = Column(String, nullable=True, index=True) + session_id = Column(String, nullable=True, index=True) + chapter_id = Column(String, nullable=True, index=True) + rubric_version = Column(String, nullable=False) + overall_score = Column(Float, nullable=False, default=0.0) + veto = Column(Boolean, nullable=False, default=False) + dimension_scores_json = Column(JSON, nullable=False) + reason_codes_json = Column(JSON, nullable=False) + evidence_refs_json = Column(JSON, nullable=False) + score_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class ReviewCaseRow(PlatformBase): + __tablename__ = "review_cases" + __table_args__ = ( + Index("idx_review_cases_status_updated_at", "status", "updated_at"), + Index("idx_review_cases_trace_updated_at", "trace_id", "updated_at"), + Index("idx_review_cases_world_status_updated_at", "world_version_id", "status", "updated_at"), + Index("idx_review_cases_session_status_updated_at", "session_id", "status", "updated_at"), + ) + + case_id = Column(String, primary_key=True) + trace_id = Column(String, nullable=True, index=True) + case_type = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, index=True) + owner_id = Column(String, nullable=True, index=True) + source_surface = Column(String, nullable=True, index=True) + world_version_id = Column(String, nullable=True, index=True) + session_id = Column(String, nullable=True, index=True) + score_id = Column(String, nullable=True, index=True) + source_ref_json = Column(JSON, nullable=False) + reason_codes_json = Column(JSON, nullable=False) + evidence_refs_json = Column(JSON, nullable=False) + case_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class QualityFeedbackItemRow(PlatformBase): + __tablename__ = "quality_feedback_items" + __table_args__ = ( + Index("idx_quality_feedback_items_trace_created_at", "trace_id", "created_at"), + Index("idx_quality_feedback_items_account_created_at", "account_id", "created_at"), + Index("idx_quality_feedback_items_session_created_at", "session_id", "created_at"), + Index("idx_quality_feedback_items_type_signal_created_at", "feedback_type", "signal", "created_at"), + ) + + feedback_item_id = Column(String, primary_key=True) + trace_id = Column(String, nullable=True, index=True) + source_event_id = Column(String, nullable=True, index=True) + feedback_type = Column(String, nullable=False, index=True) + signal = Column(String, nullable=False, index=True) + source_surface = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=True, index=True) + world_version_id = Column(String, nullable=True, index=True) + session_id = Column(String, nullable=True, index=True) + chapter_id = Column(String, nullable=True, index=True) + source_ref_json = Column(JSON, nullable=False) + payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class GroundingCheckRow(PlatformBase): + __tablename__ = "grounding_checks" + __table_args__ = ( + Index("idx_grounding_checks_trace_created_at", "trace_id", "created_at"), + Index("idx_grounding_checks_status_created_at", "status", "created_at"), + Index("idx_grounding_checks_world_created_at", "world_version_id", "created_at"), + Index("idx_grounding_checks_session_created_at", "session_id", "created_at"), + ) + + grounding_check_id = Column(String, primary_key=True) + trace_id = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, index=True) + confidence = Column(Float, nullable=False, default=0.0) + source_surface = Column(String, nullable=False, index=True) + world_version_id = Column(String, nullable=True, index=True) + session_id = Column(String, nullable=True, index=True) + chapter_id = Column(String, nullable=True, index=True) + evidence_refs_json = Column(JSON, nullable=False) + unsupported_claims_json = Column(JSON, nullable=False) + reason_codes_json = Column(JSON, nullable=False) + summary = Column(Text, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class PlanRow(PlatformBase): + __tablename__ = "plans" + __table_args__ = ( + Index("idx_plans_status_updated_at", "status", "updated_at"), + ) + + plan_id = Column(String, primary_key=True) + display_name = Column(String, nullable=False) + subscription_tier = Column(String, nullable=False, index=True) + monthly_price_usd = Column(Float, nullable=False, default=0.0) + status = Column(String, nullable=False, default="active", index=True) + seat_limit = Column(Integer, nullable=False, default=0) + workspace_limit = Column(Integer, nullable=False, default=0) + campaign_limit = Column(Integer, nullable=False, default=0) + plan_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class CustomerAccountRow(PlatformBase): + __tablename__ = "customer_accounts" + __table_args__ = ( + Index("idx_customer_accounts_status_updated_at", "status", "updated_at"), + Index("idx_customer_accounts_plan_status_updated_at", "plan_id", "status", "updated_at"), + Index("idx_customer_accounts_renewal_due_at", "renewal_due_at"), + ) + + customer_account_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, unique=True, index=True) + display_name = Column(String, nullable=True) + status = Column(String, nullable=False, default="trial", index=True) + plan_id = Column(String, nullable=False, index=True) + seat_limit = Column(Integer, nullable=False, default=0) + workspace_limit = Column(Integer, nullable=False, default=0) + campaign_limit = Column(Integer, nullable=False, default=0) + seat_count = Column(Integer, nullable=False, default=0) + workspace_count = Column(Integer, nullable=False, default=0) + campaign_count = Column(Integer, nullable=False, default=0) + renewal_due_at = Column(String, nullable=True) + metadata_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class BillingProfileRow(PlatformBase): + __tablename__ = "billing_profiles" + __table_args__ = ( + Index("idx_billing_profiles_customer_updated_at", "customer_account_id", "updated_at"), + Index("idx_billing_profiles_account_updated_at", "account_id", "updated_at"), + Index("idx_billing_profiles_provider_status_updated_at", "provider", "status", "updated_at"), + ) + + billing_profile_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + provider = Column(String, nullable=False, index=True) + provider_customer_ref = Column(String, nullable=True, index=True) + invoice_email = Column(String, nullable=True) + legal_name = Column(String, nullable=True) + billing_country = Column(String, nullable=True) + tax_status = Column(String, nullable=True) + status = Column(String, nullable=False, default="active", index=True) + profile_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class UsageLedgerRow(PlatformBase): + __tablename__ = "usage_ledgers" + __table_args__ = ( + Index("idx_usage_ledgers_account_period_updated_at", "account_id", "billing_period_start", "updated_at"), + Index("idx_usage_ledgers_customer_period_updated_at", "customer_account_id", "billing_period_start", "updated_at"), + Index("idx_usage_ledgers_status_updated_at", "status", "updated_at"), + ) + + usage_ledger_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, index=True) + customer_account_id = Column(String, nullable=True, index=True) + plan_id = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="open", index=True) + billing_period_start = Column(String, nullable=False, index=True) + billing_period_end = Column(String, nullable=False, index=True) + presented_count = Column(Integer, nullable=False, default=0) + handoff_count = Column(Integer, nullable=False, default=0) + conversion_count = Column(Integer, nullable=False, default=0) + subtotal_amount_usd = Column(Float, nullable=False, default=0.0) + disputed_amount_usd = Column(Float, nullable=False, default=0.0) + credited_amount_usd = Column(Float, nullable=False, default=0.0) + reversed_amount_usd = Column(Float, nullable=False, default=0.0) + ledger_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class BillableEventRow(PlatformBase): + __tablename__ = "billable_events" + __table_args__ = ( + Index("idx_billable_events_account_created_at", "account_id", "created_at"), + Index("idx_billable_events_customer_created_at", "customer_account_id", "created_at"), + Index("idx_billable_events_trace_created_at", "trace_id", "created_at"), + Index("idx_billable_events_metric_status_created_at", "billable_metric", "status", "created_at"), + ) + + billable_event_id = Column(String, primary_key=True) + usage_ledger_id = Column(String, nullable=True, index=True) + account_id = Column(String, nullable=False, index=True) + customer_account_id = Column(String, nullable=True, index=True) + plan_id = Column(String, nullable=True, index=True) + billable_metric = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="recorded", index=True) + trace_id = Column(String, nullable=True, index=True) + quality_event_id = Column(String, nullable=True, index=True) + runtime_receipt_event_id = Column(String, nullable=True, index=True) + feedback_item_id = Column(String, nullable=True, index=True) + source_surface = Column(String, nullable=True, index=True) + world_version_id = Column(String, nullable=True, index=True) + session_id = Column(String, nullable=True, index=True) + quantity = Column(Float, nullable=False, default=1.0) + unit_price_usd = Column(Float, nullable=False, default=0.0) + amount_usd = Column(Float, nullable=False, default=0.0) + reason_codes_json = Column(JSON, nullable=False) + event_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class InvoicePreviewRow(PlatformBase): + __tablename__ = "invoice_previews" + __table_args__ = ( + Index("idx_invoice_previews_account_period_updated_at", "account_id", "billing_period_start", "updated_at"), + Index("idx_invoice_previews_customer_period_updated_at", "customer_account_id", "billing_period_start", "updated_at"), + ) + + invoice_preview_id = Column(String, primary_key=True) + usage_ledger_id = Column(String, nullable=True, index=True) + account_id = Column(String, nullable=False, index=True) + customer_account_id = Column(String, nullable=True, index=True) + plan_id = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="draft", index=True) + billing_period_start = Column(String, nullable=False, index=True) + billing_period_end = Column(String, nullable=False, index=True) + subtotal_amount_usd = Column(Float, nullable=False, default=0.0) + credits_applied_usd = Column(Float, nullable=False, default=0.0) + disputed_amount_usd = Column(Float, nullable=False, default=0.0) + credited_amount_usd = Column(Float, nullable=False, default=0.0) + reversed_amount_usd = Column(Float, nullable=False, default=0.0) + total_due_usd = Column(Float, nullable=False, default=0.0) + line_items_json = Column(JSON, nullable=False) + summary_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class CreditBalanceRow(PlatformBase): + __tablename__ = "credit_balances" + __table_args__ = ( + Index("idx_credit_balances_account_updated_at", "account_id", "updated_at"), + Index("idx_credit_balances_customer_updated_at", "customer_account_id", "updated_at"), + Index("idx_credit_balances_type_updated_at", "balance_type", "updated_at"), + ) + + credit_balance_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, index=True) + customer_account_id = Column(String, nullable=True, index=True) + balance_type = Column(String, nullable=False, index=True) + amount_usd = Column(Float, nullable=False, default=0.0) + source_ref_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class OverageFlagRow(PlatformBase): + __tablename__ = "overage_flags" + __table_args__ = ( + Index("idx_overage_flags_account_status_updated_at", "account_id", "status", "updated_at"), + Index("idx_overage_flags_metric_status_updated_at", "metric_type", "status", "updated_at"), + ) + + overage_flag_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, index=True) + customer_account_id = Column(String, nullable=True, index=True) + plan_id = Column(String, nullable=True, index=True) + metric_type = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="active", index=True) + observed_units = Column(Float, nullable=False, default=0.0) + included_units = Column(Float, nullable=False, default=0.0) + overage_units = Column(Float, nullable=False, default=0.0) + flag_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class CampaignRow(PlatformBase): + __tablename__ = "campaigns" + __table_args__ = ( + Index("idx_campaigns_account_status_updated_at", "account_id", "activation_status", "updated_at"), + Index("idx_campaigns_customer_status_updated_at", "customer_account_id", "activation_status", "updated_at"), + ) + + campaign_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + title = Column(String, nullable=False) + target_icp_vertical = Column(String, nullable=False) + cta_text = Column(String, nullable=False) + disclosure_text = Column(Text, nullable=False) + activation_status = Column(String, nullable=False, default="draft", index=True) + selected_channels_json = Column(JSON, nullable=False) + selected_partner_refs_json = Column(JSON, nullable=False) + primary_review_case_id = Column(String, nullable=True, index=True) + latest_submission_id = Column(String, nullable=True, index=True) + campaign_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class CampaignProofBundleRow(PlatformBase): + __tablename__ = "campaign_proof_bundles" + __table_args__ = ( + Index("idx_campaign_proof_bundles_campaign_updated_at", "campaign_id", "updated_at"), + ) + + proof_bundle_id = Column(String, primary_key=True) + campaign_id = Column(String, nullable=False, index=True) + bundle_label = Column(String, nullable=False, default="default") + proof_points_json = Column(JSON, nullable=False) + source_urls_json = Column(JSON, nullable=False) + artifact_refs_json = Column(JSON, nullable=False) + bundle_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class CampaignChannelTargetRow(PlatformBase): + __tablename__ = "campaign_channel_targets" + __table_args__ = ( + Index("idx_campaign_channel_targets_campaign_priority_updated_at", "campaign_id", "priority", "updated_at"), + ) + + channel_target_id = Column(String, primary_key=True) + campaign_id = Column(String, nullable=False, index=True) + channel_name = Column(String, nullable=False, index=True) + partner_ref = Column(String, nullable=True, index=True) + priority = Column(Integer, nullable=False, default=0) + readiness_status = Column(String, nullable=False, default="selected") + target_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class CampaignReviewSubmissionRow(PlatformBase): + __tablename__ = "campaign_review_submissions" + __table_args__ = ( + Index("idx_campaign_review_submissions_campaign_updated_at", "campaign_id", "updated_at"), + Index("idx_campaign_review_submissions_review_case_updated_at", "review_case_id", "updated_at"), + Index("idx_campaign_review_submissions_status_updated_at", "status", "updated_at"), + ) + + submission_id = Column(String, primary_key=True) + campaign_id = Column(String, nullable=False, index=True) + review_case_id = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="submitted", index=True) + submitted_by = Column(String, nullable=False) + reviewer_id = Column(String, nullable=True, index=True) + decision_note = Column(Text, nullable=True) + submitted_at = Column(String, nullable=False, default=utcnow_iso) + decided_at = Column(String, nullable=True) + submission_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class PartnerRow(PlatformBase): + __tablename__ = "partners" + __table_args__ = ( + Index("idx_partners_lifecycle_updated_at", "lifecycle_status", "updated_at"), + Index("idx_partners_endpoint_health_updated_at", "endpoint_health_status", "updated_at"), + ) + + partner_id = Column(String, primary_key=True) + name = Column(String, nullable=False, index=True) + lifecycle_status = Column(String, nullable=False, default="discovered", index=True) + sla_status = Column(String, nullable=False, default="unknown") + receipt_capability = Column(String, nullable=False, default="unknown") + disclosure_readiness = Column(String, nullable=False, default="unknown") + billing_readiness = Column(String, nullable=False, default="unknown") + allowlisted_channels_json = Column(JSON, nullable=False) + primary_endpoint_url = Column(String, nullable=True) + endpoint_health_status = Column(String, nullable=False, default="unknown", index=True) + partner_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class PartnerCapabilityRow(PlatformBase): + __tablename__ = "partner_capabilities" + __table_args__ = ( + Index("idx_partner_capabilities_partner_updated_at", "partner_id", "updated_at"), + Index("idx_partner_capabilities_type_status_updated_at", "capability_type", "status", "updated_at"), + ) + + partner_capability_id = Column(String, primary_key=True) + partner_id = Column(String, nullable=False, index=True) + capability_type = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="unknown", index=True) + capability_value = Column(String, nullable=True) + capability_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class PartnerHealthCheckRow(PlatformBase): + __tablename__ = "partner_health_checks" + __table_args__ = ( + Index("idx_partner_health_checks_partner_checked_at", "partner_id", "checked_at"), + Index("idx_partner_health_checks_status_checked_at", "status", "checked_at"), + ) + + health_check_id = Column(String, primary_key=True) + partner_id = Column(String, nullable=False, index=True) + endpoint_url = Column(String, nullable=True) + status = Column(String, nullable=False, default="unknown", index=True) + status_code = Column(Integer, nullable=True) + response_time_ms = Column(Float, nullable=True) + checked_at = Column(String, nullable=False, default=utcnow_iso) + health_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class DisputeRow(PlatformBase): + __tablename__ = "disputes" + __table_args__ = ( + Index("idx_disputes_account_status_updated_at", "account_id", "status", "updated_at"), + Index("idx_disputes_customer_status_updated_at", "customer_account_id", "status", "updated_at"), + Index("idx_disputes_billable_event_updated_at", "billable_event_id", "updated_at"), + ) + + dispute_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + campaign_id = Column(String, nullable=True, index=True) + invoice_preview_id = Column(String, nullable=True, index=True) + billable_event_id = Column(String, nullable=True, index=True) + quality_event_id = Column(String, nullable=True, index=True) + trace_id = Column(String, nullable=True, index=True) + dispute_reason_code = Column(String, nullable=False) + note = Column(Text, nullable=True) + status = Column(String, nullable=False, default="open", index=True) + requested_amount_usd = Column(Float, nullable=False, default=0.0) + resolved_amount_usd = Column(Float, nullable=False, default=0.0) + requested_by = Column(String, nullable=False) + reviewer_id = Column(String, nullable=True, index=True) + resolution_note = Column(Text, nullable=True) + dispute_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class RefundRequestRow(PlatformBase): + __tablename__ = "refund_requests" + __table_args__ = ( + Index("idx_refund_requests_account_status_updated_at", "account_id", "status", "updated_at"), + Index("idx_refund_requests_dispute_updated_at", "dispute_id", "updated_at"), + ) + + refund_request_id = Column(String, primary_key=True) + dispute_id = Column(String, nullable=True, index=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + invoice_preview_id = Column(String, nullable=True, index=True) + billable_event_id = Column(String, nullable=True, index=True) + trace_id = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="requested", index=True) + requested_amount_usd = Column(Float, nullable=False, default=0.0) + approved_amount_usd = Column(Float, nullable=False, default=0.0) + requested_by = Column(String, nullable=False) + reviewer_id = Column(String, nullable=True, index=True) + refund_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class SettlementRunRow(PlatformBase): + __tablename__ = "settlement_runs" + __table_args__ = ( + Index("idx_settlement_runs_account_updated_at", "account_id", "updated_at"), + Index("idx_settlement_runs_status_updated_at", "status", "updated_at"), + ) + + settlement_run_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=True, index=True) + account_id = Column(String, nullable=True, index=True) + billing_period_start = Column(String, nullable=True, index=True) + billing_period_end = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="draft", index=True) + subtotal_amount_usd = Column(Float, nullable=False, default=0.0) + disputed_amount_usd = Column(Float, nullable=False, default=0.0) + credited_amount_usd = Column(Float, nullable=False, default=0.0) + reversed_amount_usd = Column(Float, nullable=False, default=0.0) + refunded_amount_usd = Column(Float, nullable=False, default=0.0) + net_amount_usd = Column(Float, nullable=False, default=0.0) + run_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class SettlementItemRow(PlatformBase): + __tablename__ = "settlement_items" + __table_args__ = ( + Index("idx_settlement_items_run_status_created_at", "settlement_run_id", "status", "created_at"), + ) + + settlement_item_id = Column(String, primary_key=True) + settlement_run_id = Column(String, nullable=False, index=True) + billable_event_id = Column(String, nullable=True, index=True) + invoice_preview_id = Column(String, nullable=True, index=True) + dispute_id = Column(String, nullable=True, index=True) + refund_request_id = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="approved", index=True) + amount_usd = Column(Float, nullable=False, default=0.0) + item_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class SupportCaseRow(PlatformBase): + __tablename__ = "support_cases" + __table_args__ = ( + Index("idx_support_cases_account_status_updated_at", "account_id", "status", "updated_at"), + Index("idx_support_cases_owner_status_updated_at", "owner_id", "status", "updated_at"), + ) + + support_case_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + campaign_id = Column(String, nullable=True, index=True) + invoice_preview_id = Column(String, nullable=True, index=True) + billable_event_id = Column(String, nullable=True, index=True) + quality_event_id = Column(String, nullable=True, index=True) + trace_id = Column(String, nullable=True, index=True) + case_type = Column(String, nullable=False, default="general", index=True) + subject = Column(String, nullable=False) + description = Column(Text, nullable=False) + status = Column(String, nullable=False, default="open", index=True) + priority = Column(String, nullable=False, default="medium", index=True) + requested_by = Column(String, nullable=False) + owner_id = Column(String, nullable=True, index=True) + resolution_note = Column(Text, nullable=True) + support_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ManualAdjustmentRow(PlatformBase): + __tablename__ = "manual_adjustments" + __table_args__ = ( + Index("idx_manual_adjustments_account_status_updated_at", "account_id", "status", "updated_at"), + Index("idx_manual_adjustments_dispute_updated_at", "dispute_id", "updated_at"), + ) + + adjustment_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + dispute_id = Column(String, nullable=True, index=True) + refund_request_id = Column(String, nullable=True, index=True) + invoice_preview_id = Column(String, nullable=True, index=True) + billable_event_id = Column(String, nullable=True, index=True) + adjustment_type = Column(String, nullable=False, index=True) + amount_usd = Column(Float, nullable=False, default=0.0) + status = Column(String, nullable=False, default="applied", index=True) + requested_by = Column(String, nullable=False) + reviewer_id = Column(String, nullable=True, index=True) + adjustment_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class AuditLogRow(PlatformBase): + __tablename__ = "audit_logs" + __table_args__ = ( + Index("idx_audit_logs_account_created_at", "account_id", "created_at"), + Index("idx_audit_logs_customer_created_at", "customer_account_id", "created_at"), + Index("idx_audit_logs_actor_created_at", "actor_id", "created_at"), + Index("idx_audit_logs_action_created_at", "action_type", "created_at"), + ) + + audit_log_id = Column(String, primary_key=True) + actor_id = Column(String, nullable=False, index=True) + actor_role = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=True, index=True) + customer_account_id = Column(String, nullable=True, index=True) + object_type = Column(String, nullable=False, index=True) + object_id = Column(String, nullable=False, index=True) + action_type = Column(String, nullable=False, index=True) + source_surface = Column(String, nullable=False, index=True) + customer_visible_payload_json = Column(JSON, nullable=False) + internal_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class CustomerAuditExportRow(PlatformBase): + __tablename__ = "customer_audit_exports" + __table_args__ = ( + Index("idx_customer_audit_exports_account_created_at", "account_id", "created_at"), + Index("idx_customer_audit_exports_customer_created_at", "customer_account_id", "created_at"), + ) + + audit_export_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + requested_by = Column(String, nullable=False) + period_start = Column(String, nullable=True, index=True) + period_end = Column(String, nullable=True, index=True) + export_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class DataRetentionPolicyRow(PlatformBase): + __tablename__ = "data_retention_policies" + __table_args__ = ( + Index("idx_data_retention_policies_scope_status_updated_at", "scope", "status", "updated_at"), + ) + + retention_policy_id = Column(String, primary_key=True) + scope = Column(String, nullable=False, index=True) + retention_days = Column(Integer, nullable=False, default=30) + deletion_mode = Column(String, nullable=False, default="manual_request") + status = Column(String, nullable=False, default="active", index=True) + policy_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class DataDeletionRequestRow(PlatformBase): + __tablename__ = "data_deletion_requests" + __table_args__ = ( + Index("idx_data_deletion_requests_account_status_updated_at", "account_id", "status", "updated_at"), + Index("idx_data_deletion_requests_customer_status_updated_at", "customer_account_id", "status", "updated_at"), + ) + + deletion_request_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + requested_by = Column(String, nullable=False) + scope = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="requested", index=True) + requested_payload_json = Column(JSON, nullable=False) + affected_object_counts_json = Column(JSON, nullable=False) + resolution_note = Column(Text, nullable=True) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class InvoiceIssuanceRow(PlatformBase): + __tablename__ = "invoice_issuances" + __table_args__ = ( + Index("idx_invoice_issuances_account_status_updated_at", "account_id", "status", "updated_at"), + Index("idx_invoice_issuances_customer_status_updated_at", "customer_account_id", "status", "updated_at"), + Index("idx_invoice_issuances_provider_ref_updated_at", "provider_invoice_ref", "updated_at"), + ) + + invoice_id = Column(String, primary_key=True) + invoice_preview_id = Column(String, nullable=False, index=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + provider = Column(String, nullable=False, index=True) + provider_invoice_ref = Column(String, nullable=True, index=True) + provider_customer_ref = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="draft", index=True) + currency = Column(String, nullable=False, default="USD") + subtotal_amount_usd = Column(Float, nullable=False, default=0.0) + total_due_usd = Column(Float, nullable=False, default=0.0) + hosted_invoice_url = Column(String, nullable=True) + invoice_pdf_url = Column(String, nullable=True) + issued_at = Column(String, nullable=True) + paid_at = Column(String, nullable=True) + voided_at = Column(String, nullable=True) + invoice_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class PaymentTransactionRow(PlatformBase): + __tablename__ = "payment_transactions" + __table_args__ = ( + Index("idx_payment_transactions_account_occurred_at", "account_id", "occurred_at"), + Index("idx_payment_transactions_invoice_occurred_at", "invoice_id", "occurred_at"), + Index("idx_payment_transactions_provider_ref_occurred_at", "provider_transaction_ref", "occurred_at"), + ) + + payment_transaction_id = Column(String, primary_key=True) + invoice_id = Column(String, nullable=True, index=True) + customer_account_id = Column(String, nullable=True, index=True) + account_id = Column(String, nullable=False, index=True) + provider = Column(String, nullable=False, index=True) + provider_transaction_ref = Column(String, nullable=True, index=True) + transaction_type = Column(String, nullable=False, default="payment", index=True) + status = Column(String, nullable=False, default="pending", index=True) + amount_usd = Column(Float, nullable=False, default=0.0) + currency = Column(String, nullable=False, default="USD") + trace_id = Column(String, nullable=True, index=True) + transaction_payload_json = Column(JSON, nullable=False) + occurred_at = Column(String, nullable=False, default=utcnow_iso) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class ProviderWebhookEventRow(PlatformBase): + __tablename__ = "provider_webhook_events" + __table_args__ = ( + Index("idx_provider_webhook_events_provider_created_at", "provider", "created_at"), + Index("idx_provider_webhook_events_provider_event_created_at", "provider_event_id", "created_at"), + Index("idx_provider_webhook_events_status_created_at", "status", "created_at"), + ) + + provider_webhook_event_id = Column(String, primary_key=True) + provider = Column(String, nullable=False, index=True) + provider_event_id = Column(String, nullable=False, index=True) + event_type = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="received", index=True) + invoice_id = Column(String, nullable=True, index=True) + account_id = Column(String, nullable=True, index=True) + payload_json = Column(JSON, nullable=False) + processing_result_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + processed_at = Column(String, nullable=True) + + +class CreditNoteRow(PlatformBase): + __tablename__ = "credit_notes" + __table_args__ = ( + Index("idx_credit_notes_invoice_created_at", "invoice_id", "created_at"), + Index("idx_credit_notes_provider_ref_created_at", "provider_credit_note_ref", "created_at"), + ) + + credit_note_id = Column(String, primary_key=True) + invoice_id = Column(String, nullable=False, index=True) + customer_account_id = Column(String, nullable=True, index=True) + account_id = Column(String, nullable=False, index=True) + provider = Column(String, nullable=False, index=True) + provider_credit_note_ref = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="issued", index=True) + amount_usd = Column(Float, nullable=False, default=0.0) + reason = Column(String, nullable=True) + credit_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class PaymentRetryAttemptRow(PlatformBase): + __tablename__ = "payment_retry_attempts" + __table_args__ = ( + Index("idx_payment_retry_attempts_invoice_updated_at", "invoice_id", "updated_at"), + Index("idx_payment_retry_attempts_account_updated_at", "account_id", "updated_at"), + ) + + payment_retry_attempt_id = Column(String, primary_key=True) + invoice_id = Column(String, nullable=True, index=True) + customer_account_id = Column(String, nullable=True, index=True) + account_id = Column(String, nullable=False, index=True) + provider = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="planned", index=True) + retry_reason = Column(String, nullable=True) + attempt_count = Column(Integer, nullable=False, default=1) + next_retry_at = Column(String, nullable=True) + retry_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class DunningEventRow(PlatformBase): + __tablename__ = "dunning_events" + __table_args__ = ( + Index("idx_dunning_events_invoice_created_at", "invoice_id", "created_at"), + Index("idx_dunning_events_account_created_at", "account_id", "created_at"), + ) + + dunning_event_id = Column(String, primary_key=True) + invoice_id = Column(String, nullable=True, index=True) + customer_account_id = Column(String, nullable=True, index=True) + account_id = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="scheduled", index=True) + step = Column(String, nullable=False, index=True) + event_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class RenewalTrackerRow(PlatformBase): + __tablename__ = "renewal_trackers" + __table_args__ = ( + Index("idx_renewal_trackers_account_status_updated_at", "account_id", "status", "updated_at"), + ) + + renewal_tracker_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="stable", index=True) + renewal_due_at = Column(String, nullable=True) + tracker_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class DunningRunRow(PlatformBase): + __tablename__ = "dunning_runs" + __table_args__ = ( + Index("idx_dunning_runs_account_status_updated_at", "account_id", "status", "updated_at"), + ) + + dunning_run_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + invoice_id = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="open", index=True) + current_step = Column(String, nullable=False, default="initial_notice") + dunning_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class PilotConversionTrackRow(PlatformBase): + __tablename__ = "pilot_conversion_tracks" + __table_args__ = ( + Index("idx_pilot_conversion_tracks_account_status_updated_at", "account_id", "status", "updated_at"), + ) + + pilot_conversion_track_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="watch", index=True) + track_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ExpansionCandidateRow(PlatformBase): + __tablename__ = "expansion_candidates" + __table_args__ = ( + Index("idx_expansion_candidates_account_status_updated_at", "account_id", "status", "updated_at"), + ) + + expansion_candidate_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="watch", index=True) + trigger_type = Column(String, nullable=False, index=True) + candidate_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ChurnRiskFlagRow(PlatformBase): + __tablename__ = "churn_risk_flags" + __table_args__ = ( + Index("idx_churn_risk_flags_account_status_updated_at", "account_id", "status", "updated_at"), + ) + + churn_risk_flag_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="watch", index=True) + risk_level = Column(String, nullable=False, default="medium", index=True) + flag_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ProductionSignoffRow(PlatformBase): + __tablename__ = "production_signoffs" + __table_args__ = ( + Index("idx_production_signoffs_status_updated_at", "status", "updated_at"), + Index("idx_production_signoffs_launch_label_updated_at", "launch_label", "updated_at"), + ) + + signoff_id = Column(String, primary_key=True) + launch_label = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="draft", index=True) + source_go_live_checklist_id = Column(String, nullable=True) + source_manual_signoff_bundle_id = Column(String, nullable=True) + rollup_summary_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ProductionSignoffItemRow(PlatformBase): + __tablename__ = "production_signoff_items" + __table_args__ = ( + Index("idx_production_signoff_items_signoff_status_due_at", "signoff_id", "status", "due_at"), + Index("idx_production_signoff_items_owner_status_due_at", "owner_role", "status", "due_at"), + Index("idx_production_signoff_items_code_status_updated_at", "item_code", "status", "updated_at"), + ) + + signoff_item_id = Column(String, primary_key=True) + signoff_id = Column(String, nullable=False, index=True) + item_code = Column(String, nullable=False, index=True) + category = Column(String, nullable=False, index=True) + label = Column(Text, nullable=False) + owner_role = Column(String, nullable=False, index=True) + owner_actor_id = Column(String, nullable=True, index=True) + due_at = Column(String, nullable=True) + status = Column(String, nullable=False, default="pending", index=True) + decision_note = Column(Text, nullable=True) + approved_at = Column(String, nullable=True) + evidence_count = Column(Integer, nullable=False, default=0) + item_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ProductionSignoffEvidenceRow(PlatformBase): + __tablename__ = "production_signoff_evidence" + __table_args__ = ( + Index("idx_production_signoff_evidence_item_created_at", "signoff_item_id", "created_at"), + Index("idx_production_signoff_evidence_signoff_created_at", "signoff_id", "created_at"), + ) + + evidence_id = Column(String, primary_key=True) + signoff_id = Column(String, nullable=False, index=True) + signoff_item_id = Column(String, nullable=False, index=True) + evidence_type = Column(String, nullable=False, index=True) + source_ref_json = Column(JSON, nullable=False) + summary = Column(Text, nullable=True) + customer_safe = Column(Boolean, nullable=False, default=False) + payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class ProductionCutoverWindowRow(PlatformBase): + __tablename__ = "production_cutover_windows" + __table_args__ = ( + Index("idx_production_cutover_windows_signoff_status_starts_at", "signoff_id", "status", "starts_at"), + Index("idx_production_cutover_windows_env_status_starts_at", "target_environment", "status", "starts_at"), + ) + + cutover_window_id = Column(String, primary_key=True) + signoff_id = Column(String, nullable=False, index=True) + launch_wave = Column(String, nullable=False, index=True) + target_environment = Column(String, nullable=False, index=True) + starts_at = Column(String, nullable=True) + ends_at = Column(String, nullable=True) + rollback_owner_role = Column(String, nullable=True) + status = Column(String, nullable=False, default="planned", index=True) + cutover_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ProductionCustomerAcceptanceRecordRow(PlatformBase): + __tablename__ = "production_customer_acceptance_records" + __table_args__ = ( + Index("idx_production_customer_acceptance_account_status_updated_at", "account_id", "status", "updated_at"), + Index("idx_production_customer_acceptance_wave_status_updated_at", "launch_wave", "status", "updated_at"), + ) + + acceptance_record_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + signoff_id = Column(String, nullable=True, index=True) + launch_wave = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="draft", index=True) + readiness_summary_json = Column(JSON, nullable=False) + acceptance_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class GoLiveReadyAccountRow(PlatformBase): + __tablename__ = "go_live_ready_accounts" + __table_args__ = ( + Index("idx_go_live_ready_accounts_wave_status_updated_at", "launch_wave", "status", "updated_at"), + Index("idx_go_live_ready_accounts_account_status_updated_at", "account_id", "status", "updated_at"), + ) + + go_live_ready_account_id = Column(String, primary_key=True) + customer_account_id = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=False, index=True) + acceptance_record_id = Column(String, nullable=False, index=True) + launch_wave = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="candidate", index=True) + readiness_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class LaunchWaveStatusRow(PlatformBase): + __tablename__ = "launch_wave_statuses" + __table_args__ = ( + Index("idx_launch_wave_statuses_wave_status_updated_at", "launch_wave", "status", "updated_at"), + ) + + launch_wave_status_id = Column(String, primary_key=True) + launch_wave = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="planned", index=True) + target_environment = Column(String, nullable=False, default="production") + wave_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ProductionPreflightRunRow(PlatformBase): + __tablename__ = "production_preflight_runs" + __table_args__ = ( + Index("idx_production_preflight_runs_signoff_status_updated_at", "signoff_id", "status", "updated_at"), + Index("idx_production_preflight_runs_wave_status_updated_at", "launch_wave", "status", "updated_at"), + ) + + preflight_run_id = Column(String, primary_key=True) + signoff_id = Column(String, nullable=True, index=True) + launch_wave = Column(String, nullable=False, index=True) + target_environment = Column(String, nullable=False, default="production", index=True) + status = Column(String, nullable=False, default="running", index=True) + go_no_go = Column(String, nullable=False, default="manual_review", index=True) + hard_fail_count = Column(Integer, nullable=False, default=0) + soft_fail_count = Column(Integer, nullable=False, default=0) + run_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ProductionPreflightCheckRow(PlatformBase): + __tablename__ = "production_preflight_checks" + __table_args__ = ( + Index("idx_production_preflight_checks_run_status_created_at", "preflight_run_id", "status", "created_at"), + Index("idx_production_preflight_checks_linked_item_status_created_at", "linked_signoff_item_code", "status", "created_at"), + ) + + preflight_check_id = Column(String, primary_key=True) + preflight_run_id = Column(String, nullable=False, index=True) + check_key = Column(String, nullable=False, index=True) + linked_signoff_item_code = Column(String, nullable=True, index=True) + owner_role = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="passed", index=True) + summary = Column(Text, nullable=True) + evidence_ref = Column(Text, nullable=True) + payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class First7DayOutcomeRow(PlatformBase): + __tablename__ = "first_7_day_outcomes" + __table_args__ = ( + Index("idx_first_7_day_outcomes_account_generated_at", "account_id", "generated_at"), + Index("idx_first_7_day_outcomes_wave_generated_at", "launch_wave", "generated_at"), + ) + + first_7_day_outcome_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, index=True) + customer_account_id = Column(String, nullable=True, index=True) + launch_wave = Column(String, nullable=False, index=True) + launch_anchor_at = Column(String, nullable=True) + outcome_payload_json = Column(JSON, nullable=False) + generated_at = Column(String, nullable=False, default=utcnow_iso) + + +class First30DayValueSummaryRow(PlatformBase): + __tablename__ = "first_30_day_value_summaries" + __table_args__ = ( + Index("idx_first_30_day_value_summaries_account_generated_at", "account_id", "generated_at"), + Index("idx_first_30_day_value_summaries_wave_generated_at", "launch_wave", "generated_at"), + ) + + first_30_day_value_summary_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, index=True) + customer_account_id = Column(String, nullable=True, index=True) + launch_wave = Column(String, nullable=False, index=True) + launch_anchor_at = Column(String, nullable=True) + provisional = Column(Boolean, nullable=False, default=True) + summary_payload_json = Column(JSON, nullable=False) + generated_at = Column(String, nullable=False, default=utcnow_iso) + + +class PilotToPaidReadinessScoreRow(PlatformBase): + __tablename__ = "pilot_to_paid_readiness_scores" + __table_args__ = ( + Index("idx_pilot_to_paid_readiness_scores_account_generated_at", "account_id", "generated_at"), + Index("idx_pilot_to_paid_readiness_scores_wave_generated_at", "launch_wave", "generated_at"), + ) + + pilot_to_paid_readiness_score_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, index=True) + customer_account_id = Column(String, nullable=True, index=True) + launch_wave = Column(String, nullable=False, index=True) + launch_anchor_at = Column(String, nullable=True) + score = Column(Float, nullable=False, default=0.0) + band = Column(String, nullable=False, default="watch", index=True) + score_payload_json = Column(JSON, nullable=False) + generated_at = Column(String, nullable=False, default=utcnow_iso) + + +class CustomerSuccessSnapshotRow(PlatformBase): + __tablename__ = "customer_success_snapshots" + __table_args__ = ( + Index("idx_customer_success_snapshots_account_generated_at", "account_id", "generated_at"), + Index("idx_customer_success_snapshots_wave_generated_at", "launch_wave", "generated_at"), + ) + + customer_success_snapshot_id = Column(String, primary_key=True) + account_id = Column(String, nullable=False, index=True) + customer_account_id = Column(String, nullable=True, index=True) + launch_wave = Column(String, nullable=False, index=True) + launch_anchor_at = Column(String, nullable=True) + snapshot_payload_json = Column(JSON, nullable=False) + generated_at = Column(String, nullable=False, default=utcnow_iso) + + +class ProductionLaunchEventRow(PlatformBase): + __tablename__ = "production_launch_events" + __table_args__ = ( + Index("idx_production_launch_events_wave_phase_occurred_at", "launch_wave", "phase", "occurred_at"), + Index("idx_production_launch_events_account_severity_occurred_at", "account_id", "severity", "occurred_at"), + ) + + launch_event_id = Column(String, primary_key=True) + launch_wave = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=True, index=True) + event_category = Column(String, nullable=False, index=True) + event_type = Column(String, nullable=False, index=True) + phase = Column(String, nullable=False, index=True) + severity = Column(String, nullable=False, default="info", index=True) + related_object_type = Column(String, nullable=True, index=True) + related_object_id = Column(String, nullable=True, index=True) + occurred_at = Column(String, nullable=False, default=utcnow_iso) + event_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class ProductionPostmortemRecordRow(PlatformBase): + __tablename__ = "production_postmortem_records" + __table_args__ = ( + Index("idx_production_postmortem_records_wave_status_generated_at", "launch_wave", "status", "generated_at"), + Index("idx_production_postmortem_records_account_status_generated_at", "account_id", "status", "generated_at"), + ) + + postmortem_record_id = Column(String, primary_key=True) + launch_wave = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="draft", index=True) + summary_json = Column(JSON, nullable=False) + generated_at = Column(String, nullable=False, default=utcnow_iso) + + +class GoLiveDayRunRow(PlatformBase): + __tablename__ = "go_live_day_runs" + __table_args__ = ( + Index("idx_go_live_day_runs_wave_status_updated_at", "launch_wave", "status", "updated_at"), + ) + + go_live_day_run_id = Column(String, primary_key=True) + signoff_id = Column(String, nullable=True, index=True) + launch_wave = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="running", index=True) + activation_state_before = Column(String, nullable=True) + activation_state_after = Column(String, nullable=True) + report_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + updated_at = Column(String, nullable=False, default=utcnow_iso) + + +class GoLiveDayCheckpointRow(PlatformBase): + __tablename__ = "go_live_day_checkpoints" + __table_args__ = ( + Index("idx_go_live_day_checkpoints_run_created_at", "go_live_day_run_id", "created_at"), + Index("idx_go_live_day_checkpoints_key_status_created_at", "checkpoint_key", "status", "created_at"), + ) + + go_live_day_checkpoint_id = Column(String, primary_key=True) + go_live_day_run_id = Column(String, nullable=False, index=True) + checkpoint_key = Column(String, nullable=False, index=True) + status = Column(String, nullable=False, default="passed", index=True) + summary = Column(Text, nullable=True) + evidence_ref = Column(Text, nullable=True) + rollback_recommendation = Column(String, nullable=True) + checkpoint_payload_json = Column(JSON, nullable=False) + created_at = Column(String, nullable=False, default=utcnow_iso) + + +class LaunchWeekGuardRunRow(PlatformBase): + __tablename__ = "launch_week_guard_runs" + __table_args__ = ( + Index("idx_launch_week_guard_runs_wave_status_generated_at", "launch_wave", "status", "generated_at"), + ) + + launch_week_guard_run_id = Column(String, primary_key=True) + launch_wave = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="not_ready", index=True) + replication_readiness = Column(String, nullable=False, default="not_ready", index=True) + summary_json = Column(JSON, nullable=False) + generated_at = Column(String, nullable=False, default=utcnow_iso) + + +class FirstCustomerSuccessPackRow(PlatformBase): + __tablename__ = "first_customer_success_packs" + __table_args__ = ( + Index("idx_first_customer_success_packs_wave_status_generated_at", "launch_wave", "status", "generated_at"), + ) + + first_customer_success_pack_id = Column(String, primary_key=True) + launch_wave = Column(String, nullable=False, index=True) + account_id = Column(String, nullable=True, index=True) + status = Column(String, nullable=False, default="not_ready", index=True) + pack_payload_json = Column(JSON, nullable=False) + generated_at = Column(String, nullable=False, default=utcnow_iso) + + +def _is_file_sqlite_url(database_url: str) -> bool: + lowered = str(database_url or "").lower() + return lowered.startswith("sqlite:///") and lowered not in {"sqlite://", "sqlite:///:memory:"} + + +def _int_env(name: str, default: int) -> int: + try: + return int(str(os.getenv(name, "") or "").strip() or default) + except (TypeError, ValueError): + return default + + +def _postgres_engine_options() -> dict: + is_serverless = bool(os.getenv("VERCEL")) + return { + "pool_pre_ping": True, + "pool_recycle": _int_env("NARRATIVEOS_DB_POOL_RECYCLE_SECONDS", 300), + "pool_timeout": _int_env("NARRATIVEOS_DB_POOL_TIMEOUT_SECONDS", 10), + "pool_size": _int_env("NARRATIVEOS_DB_POOL_SIZE", 1 if is_serverless else 5), + "max_overflow": _int_env("NARRATIVEOS_DB_MAX_OVERFLOW", 2 if is_serverless else 10), + "connect_args": { + "connect_timeout": _int_env("NARRATIVEOS_DB_CONNECT_TIMEOUT_SECONDS", 10), + }, + } + + +def create_platform_engine(database_url: str): + if not str(database_url or "").lower().startswith("sqlite:"): + return create_engine(database_url, future=True, **_postgres_engine_options()) + + engine = create_engine( + database_url, + future=True, + connect_args={ + "check_same_thread": False, + "timeout": 30, + }, + ) + + @event.listens_for(engine, "connect") + def _configure_sqlite_runtime(dbapi_connection, _connection_record) -> None: # type: ignore[no-untyped-def] + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA busy_timeout=30000") + if _is_file_sqlite_url(database_url): + try: + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + except Exception: + # Some sqlite URLs can be read-only or backed by virtual files. + # Busy timeout is still useful there, so do not fail engine creation. + pass + finally: + cursor.close() + + return engine + + +SQLITE_COMPATIBILITY_COLUMNS = { + "auth_identity_profiles": ( + { + "name": "pending_email_address", + "ddl": "ALTER TABLE auth_identity_profiles ADD COLUMN pending_email_address VARCHAR", + }, + { + "name": "avatar_url", + "ddl": "ALTER TABLE auth_identity_profiles ADD COLUMN avatar_url VARCHAR", + }, + { + "name": "pending_email_change_requested_at", + "ddl": "ALTER TABLE auth_identity_profiles ADD COLUMN pending_email_change_requested_at VARCHAR", + }, + { + "name": "email_change_last_sent_at", + "ddl": "ALTER TABLE auth_identity_profiles ADD COLUMN email_change_last_sent_at VARCHAR", + }, + { + "name": "ui_preferences_json", + "ddl": "ALTER TABLE auth_identity_profiles ADD COLUMN ui_preferences_json JSON", + }, + { + "name": "deactivated_at", + "ddl": "ALTER TABLE auth_identity_profiles ADD COLUMN deactivated_at VARCHAR", + }, + { + "name": "deactivated_by", + "ddl": "ALTER TABLE auth_identity_profiles ADD COLUMN deactivated_by VARCHAR", + }, + { + "name": "deactivation_reason", + "ddl": "ALTER TABLE auth_identity_profiles ADD COLUMN deactivation_reason TEXT", + }, + ), + "author_works": ( + { + "name": "branch_id", + "ddl": "ALTER TABLE author_works ADD COLUMN branch_id VARCHAR", + }, + { + "name": "root_work_id", + "ddl": "ALTER TABLE author_works ADD COLUMN root_work_id VARCHAR", + "backfill": "UPDATE author_works SET root_work_id = work_id WHERE root_work_id IS NULL", + }, + { + "name": "parent_work_id", + "ddl": "ALTER TABLE author_works ADD COLUMN parent_work_id VARCHAR", + }, + { + "name": "branch_name", + "ddl": "ALTER TABLE author_works ADD COLUMN branch_name VARCHAR", + "backfill": "UPDATE author_works SET branch_name = '主线' WHERE branch_name IS NULL", + }, + { + "name": "branch_kind", + "ddl": "ALTER TABLE author_works ADD COLUMN branch_kind VARCHAR", + "backfill": "UPDATE author_works SET branch_kind = 'mainline' WHERE branch_kind IS NULL", + }, + { + "name": "branch_origin_label", + "ddl": "ALTER TABLE author_works ADD COLUMN branch_origin_label TEXT", + }, + { + "name": "fork_after_chapter_index", + "ddl": "ALTER TABLE author_works ADD COLUMN fork_after_chapter_index INTEGER", + "backfill": "UPDATE author_works SET fork_after_chapter_index = 0 WHERE fork_after_chapter_index IS NULL", + }, + { + "name": "is_active_line", + "ddl": "ALTER TABLE author_works ADD COLUMN is_active_line INTEGER DEFAULT 0", + "backfill": "UPDATE author_works SET is_active_line = 1 WHERE is_active_line IS NULL", + }, + ), + "entitlements": ( + { + "name": "account_id", + "ddl": "ALTER TABLE entitlements ADD COLUMN account_id VARCHAR", + "backfill": "UPDATE entitlements SET account_id = reader_id WHERE account_id IS NULL", + }, + { + "name": "wallet_type", + "ddl": "ALTER TABLE entitlements ADD COLUMN wallet_type VARCHAR", + }, + { + "name": "tier_id", + "ddl": "ALTER TABLE entitlements ADD COLUMN tier_id VARCHAR", + }, + ), + "subscriptions": ( + { + "name": "account_id", + "ddl": "ALTER TABLE subscriptions ADD COLUMN account_id VARCHAR", + }, + ), + "billing_checkout_sessions": ( + { + "name": "checkout_kind", + "ddl": "ALTER TABLE billing_checkout_sessions ADD COLUMN checkout_kind VARCHAR DEFAULT 'subscription'", + "backfill": "UPDATE billing_checkout_sessions SET checkout_kind = 'subscription' WHERE checkout_kind IS NULL", + }, + { + "name": "package_id", + "ddl": "ALTER TABLE billing_checkout_sessions ADD COLUMN package_id VARCHAR", + }, + { + "name": "fulfilled_at", + "ddl": "ALTER TABLE billing_checkout_sessions ADD COLUMN fulfilled_at VARCHAR", + }, + ), + "usage_meters": ( + { + "name": "account_id", + "ddl": "ALTER TABLE usage_meters ADD COLUMN account_id VARCHAR", + "backfill": "UPDATE usage_meters SET account_id = reader_id WHERE account_id IS NULL", + }, + { + "name": "wallet_type", + "ddl": "ALTER TABLE usage_meters ADD COLUMN wallet_type VARCHAR", + }, + { + "name": "subscription_tier", + "ddl": "ALTER TABLE usage_meters ADD COLUMN subscription_tier VARCHAR", + }, + { + "name": "provider", + "ddl": "ALTER TABLE usage_meters ADD COLUMN provider VARCHAR", + }, + ), +} + + +def bootstrap_sqlite_schema(engine: Engine) -> None: + inspector = inspect(engine) + table_names = set(inspector.get_table_names()) + if not table_names: + return + with engine.begin() as connection: + for table_name, column_specs in SQLITE_COMPATIBILITY_COLUMNS.items(): + if table_name not in table_names: + continue + current_columns = { + str(row[1]) + for row in connection.exec_driver_sql(f"PRAGMA table_info('{table_name}')") + } + for spec in column_specs: + if spec["name"] in current_columns: + continue + connection.exec_driver_sql(spec["ddl"]) + if spec.get("backfill"): + connection.exec_driver_sql(spec["backfill"]) def is_postgres_url(database_url: str) -> bool: @@ -419,6 +2340,13 @@ def bootstrap_postgres_schema(engine: Engine, schema_path: Path = POSTGRES_SCHEM def create_platform_session_local(database_url: str): engine = create_platform_engine(database_url) if is_postgres_url(database_url): + # On a fresh managed Postgres, ensure base tables exist before replaying + # our append-only SQL migration chain, which may contain backfills. + PlatformBase.metadata.create_all(engine) bootstrap_postgres_schema(engine) + elif database_url.lower().startswith("sqlite:"): + bootstrap_sqlite_schema(engine) PlatformBase.metadata.create_all(engine) + if database_url.lower().startswith("sqlite:"): + bootstrap_sqlite_schema(engine) return engine, sessionmaker(bind=engine, expire_on_commit=False, class_=Session) diff --git a/src/narrativeos/persistence/migrations.py b/src/narrativeos/persistence/migrations.py index 3fd76bf..a3f15ea 100644 --- a/src/narrativeos/persistence/migrations.py +++ b/src/narrativeos/persistence/migrations.py @@ -4,6 +4,7 @@ import hashlib import importlib.util import json +import os from pathlib import Path from typing import Any, Iterable, List, Optional @@ -331,9 +332,10 @@ def bootstrap_schema_lifecycle( before = inspect_schema_lifecycle(engine, migrations_dir=migrations_dir, schema_path=schema_path) applied_now: List[str] = [] alembic_action: Optional[dict] = None + skip_runtime_alembic = bool(os.getenv("VERCEL")) or str(os.getenv("NARRATIVEOS_SKIP_RUNTIME_ALEMBIC", "")).strip().lower() in {"1", "true", "yes", "on"} if apply and before["pending_versions"]: applied_now = apply_pending_migrations(engine, migrations_dir=migrations_dir) - if apply and _repo_schema_paths(migrations_dir=migrations_dir, schema_path=schema_path) and before["alembic"]["enabled"]: + if apply and not skip_runtime_alembic and _repo_schema_paths(migrations_dir=migrations_dir, schema_path=schema_path) and before["alembic"]["enabled"]: current_revision = before["alembic"]["current_revision"] head_revision = before["alembic"]["head_revision"] if head_revision and current_revision != head_revision: @@ -349,6 +351,7 @@ def bootstrap_schema_lifecycle( "changed": bool(applied_now), "dry_run": not apply, "alembic_action": alembic_action, + "runtime_alembic_skipped": skip_runtime_alembic, } diff --git a/src/narrativeos/persistence/repositories.py b/src/narrativeos/persistence/repositories.py index 4b9d1a5..171d5df 100644 --- a/src/narrativeos/persistence/repositories.py +++ b/src/narrativeos/persistence/repositories.py @@ -1,20 +1,27 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone +import json from math import sqrt import os from typing import Any, Dict, List, Optional from uuid import uuid4 -from sqlalchemy import desc, select -from sqlalchemy.exc import IntegrityError +from sqlalchemy import delete, desc, func, or_, select, update +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from ..runtime_env import load_local_env +from ..eval.scorers import LONGFORM_SOFT_ISSUE_THRESHOLDS +from ..eval.validators import LONGFORM_Q03_SIGNAL_THRESHOLDS +from ..long_route_quality import repair_reader_view_for_display from ..models import ( CandidateBatch, + ChapterPlan, EvaluationReport, EventAtom, NarrativeState, NarrativeViewModel, + PromiseLedgerEntry, RenderedScene, RouteCandidate, SceneBeat, @@ -27,40 +34,1560 @@ from ..worldpacks.models import RuntimeBundle, WorldPack, WorldVersion from ..worldpacks.registry import FileSystemWorldRegistry, runtime_bundle_from_worldpack_data from .db import ( + AuditLogRow, AnalyticsEventRow, + AuthorProjectGraphRow, + AuthDeliveryAttemptRow, AuthIdentityRow, + AuthIdentityProfileRow, + AuthFlowTokenRow, AuthTokenRow, - BillingCheckoutSessionRow, - BillingLifecycleEventRow, - BillingRetryAttemptRow, AuthorApprovalRecordRow, AuthorCommentMessageRow, + AuthorCommentThreadRow, AuthorDraftWatcherRow, AuthorNotificationRow, AuthorNotificationPreferenceRow, - AuthorCommentThreadRow, AuthorThreadWatcherRow, + AuthorWorkChapterRow, + AuthorWorkRevisionRow, + AuthorWorkRow, + BillingCheckoutSessionRow, + BillingProfileRow, + BillingLifecycleEventRow, + BillingRetryAttemptRow, + BillableEventRow, + CampaignChannelTargetRow, + CampaignProofBundleRow, + CampaignReviewSubmissionRow, + CampaignRow, ChapterRow, + ChurnRiskFlagRow, + ContentQualityScoreRow, + CreditBalanceRow, + CustomerSuccessSnapshotRow, + First30DayValueSummaryRow, + First7DayOutcomeRow, + GoLiveReadyAccountRow, + GoLiveDayCheckpointRow, + GoLiveDayRunRow, + LaunchWaveStatusRow, + LaunchWeekGuardRunRow, + LibraryFollowRow, + LibraryStatsCubeRow, + LibraryWorkFavoriteRow, + CustomerAuditExportRow, + CustomerAccountRow, + DataDeletionRequestRow, + DataRetentionPolicyRow, + DisputeRow, + DunningEventRow, + DunningRunRow, + ExpansionCandidateRow, EntitlementRow, + InvoicePreviewRow, + InvoiceIssuanceRow, + ManualAdjustmentRow, + GeneratedMediaAssetRow, + OpsReviewItemRow, + OverageFlagRow, + OpsConfigRow, + PlanRow, + ProductionCustomerAcceptanceRecordRow, + ProductionCutoverWindowRow, + ProductionLaunchEventRow, + ProductionPostmortemRecordRow, + ProductionPreflightCheckRow, + ProductionPreflightRunRow, + ProductionSignoffEvidenceRow, + ProductionSignoffItemRow, + ProductionSignoffRow, + PartnerCapabilityRow, + PartnerHealthCheckRow, + PartnerRow, + PaymentRetryAttemptRow, + PaymentTransactionRow, + PilotToPaidReadinessScoreRow, + PilotConversionTrackRow, + ProviderWebhookEventRow, + CreditNoteRow, + ProviderSubscriptionRow, + GroundingCheckRow, + QualityFeedbackItemRow, + QualityEventRow, + QualityPolicyRow, + RefundRequestRow, ReviewRecordRow, + ReviewCaseRow, + RenewalTrackerRow, RouteChoiceRow, SessionRow, + SoulProfilePreferenceRow, + ShowcaseWorkCommentRow, + ShowcaseWorkLikeRow, + ShowcaseWorkViewRow, + StorySessionBookmarkRow, + StorySessionShareTokenRow, + ShowcaseWorkTipRow, SubscriptionRow, + SupportCaseRow, + SettlementItemRow, + SettlementRunRow, + UsageLedgerRow, UsageMeterRow, + FirstCustomerSuccessPackRow, WorldRow, WorldVersionRow, create_platform_session_local, utcnow_iso, ) +load_local_env() + + +LEAN_REPLAY_SCHEMA_VERSION = "chapter_replay_plan/v2" +FULL_STEP_RECORD_ENV = "NARRATIVEOS_STORE_FULL_STEP_RECORD" +DATABASE_FAILOVER_SQLITE_ENV = "NARRATIVEOS_DATABASE_FAILOVER_SQLITE" +DATABASE_FAILOVER_SQLITE_URL_ENV = "NARRATIVEOS_DATABASE_FAILOVER_SQLITE_URL" + + +def _store_full_step_record_enabled() -> bool: + return str(os.environ.get(FULL_STEP_RECORD_ENV, "")).strip().lower() in {"1", "true", "yes", "on"} + + +def _env_enabled(name: str) -> bool: + return str(os.environ.get(name, "") or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _is_postgres_database_url(database_url: str) -> bool: + return str(database_url or "").strip().lower().startswith(("postgres://", "postgresql://")) + + +def _serverless_sqlite_failover_url() -> str: + configured = str(os.environ.get(DATABASE_FAILOVER_SQLITE_URL_ENV, "") or "").strip() + return configured or "sqlite:////tmp/narrativeos_beta.db" + + +def _bounded_list(values: List[Any], *, limit: int, tail: bool = True) -> List[Any]: + items = list(values or []) + if limit <= 0 or len(items) <= limit: + return items + return items[-limit:] if tail else items[:limit] + + +def _compact_state_for_replay(state: NarrativeState) -> Dict[str, Any]: + """Keep replay/deviation state while dropping long-route memory blobs.""" + state_id = str(getattr(state, "state_id", "") or "") + compacted_state_id = state_id if len(state_id) <= 128 else f"{state.world_id}:{state.turn_index}:{state.chapter_index}" + return { + "state_id": compacted_state_id, + "world_id": state.world_id, + "turn_index": int(state.turn_index), + "story_phase": state.story_phase, + "chapter_index": int(state.chapter_index), + "min_end_turn": int(state.min_end_turn), + "fate_pressure": float(state.fate_pressure), + "karmic_weather": dict(state.karmic_weather), + "unresolved_debts": _bounded_list(list(state.unresolved_debts), limit=24), + "world_facts": _bounded_list(list(state.world_facts), limit=24), + "timeline": _bounded_list(list(state.timeline), limit=24), + "characters": {}, + "relationship_graph": [], + "open_promises": _bounded_list([promise.to_dict() for promise in state.open_promises], limit=32), + "tension": float(state.tension), + "themes": dict(state.themes), + "player_intent": dict(state.player_intent), + "recent_scene_functions": _bounded_list(list(state.recent_scene_functions), limit=16), + "visited_event_ids": _bounded_list(list(state.visited_event_ids), limit=160), + "route_fingerprint": _bounded_list(list(state.route_fingerprint), limit=160), + "rating_ceiling": state.rating_ceiling, + "current_series_id": state.current_series_id, + "current_volume_id": state.current_volume_id, + "current_arc_id": state.current_arc_id, + "current_chapter_task": dict(state.current_chapter_task or {}), + "word_budget": int(state.word_budget), + "metadata": { + "compacted_for_replay": True, + "source_state_id": state_id[:256], + "open_promise_count": len(state.open_promises), + "route_fingerprint_count": len(state.route_fingerprint), + "visited_event_count": len(state.visited_event_ids), + }, + } + + +def _lean_rendered_scene_payload(step_record: StepRecord) -> Optional[Dict[str, Any]]: + if step_record.rendered_scene is not None: + payload = step_record.rendered_scene.to_dict() + elif step_record.reader_view is not None: + scene_card = dict(step_record.reader_view.scene_card or {}) + payload = { + "event_id": step_record.chosen_event.event_id if step_record.chosen_event else "", + "concise_summary": str(scene_card.get("summary") or step_record.reader_view.recap or ""), + "interactive_scene": "", + "premium_prose": step_record.reader_view.body, + "story_title": step_record.reader_view.chapter_title, + "chapter_summary": step_record.reader_view.recap, + "pull_quote": str(scene_card.get("quote") or scene_card.get("pull_quote") or ""), + "story_beats": list(scene_card.get("story_beats") or scene_card.get("beats") or []), + "visual_details": list(scene_card.get("visual_details") or []), + "debug": {}, + } + else: + return None + debug = dict(payload.get("debug") or {}) + payload["debug"] = { + key: debug[key] + for key in ["backend_routing", "backend_error", "renderer", "template_fallback"] + if key in debug + } + return payload + + +def _lean_step_replay_payload(step_record: StepRecord) -> Dict[str, Any]: + rendered_scene = _lean_rendered_scene_payload(step_record) + return { + "schema_version": LEAN_REPLAY_SCHEMA_VERSION, + "session_id": step_record.session_id, + "step_index": int(step_record.step_index), + "player_input": step_record.player_input, + "intent_vector": dict(step_record.intent_vector), + "candidate_batch_debug": dict(step_record.candidate_batch.debug or {}), + "chosen_event": step_record.chosen_event.to_dict() if step_record.chosen_event else None, + "chapter_plan": step_record.chapter_plan.to_dict() if step_record.chapter_plan else None, + "scene_beats": [beat.to_dict() for beat in step_record.scene_beats], + "scene_render_spec": step_record.scene_render_spec.to_dict() if step_record.scene_render_spec else None, + "rendered_scene": rendered_scene, + "reader_view": step_record.reader_view.to_dict() if step_record.reader_view else None, + "state_before": _compact_state_for_replay(step_record.state_before), + "state_after": _compact_state_for_replay(step_record.state_after), + "critic_trace": [dict(item) for item in _bounded_list(step_record.critic_trace, limit=24)], + "promise_ledger_snapshot": _bounded_list( + [promise.to_dict() for promise in step_record.promise_ledger_snapshot], + limit=32, + ), + "created_at": step_record.created_at, + "metadata": {"storage_mode": "lean_replay", **dict(step_record.metadata or {})}, + } + + +def _chapter_plan_json_for_step(step_record: StepRecord) -> Dict[str, Any]: + if _store_full_step_record_enabled(): + return { + "schema_version": "chapter_full_step_record/v1", + "storage_mode": "full_step_record", + "step_record": step_record.to_dict(), + "chapter_plan": step_record.chapter_plan.to_dict() if step_record.chapter_plan else None, + } + replay = _lean_step_replay_payload(step_record) + return { + "schema_version": LEAN_REPLAY_SCHEMA_VERSION, + "storage_mode": "lean_replay", + "replay": replay, + "chapter_plan": replay.get("chapter_plan"), + } + + +def _replay_payload_from_plan(plan_json: Optional[Dict[str, Any]]) -> Dict[str, Any]: + plan = dict(plan_json or {}) + if isinstance(plan.get("replay"), dict): + return dict(plan["replay"]) + if isinstance(plan.get("step_record"), dict): + payload = dict(plan["step_record"]) + payload.setdefault("schema_version", "chapter_full_step_record/v1") + return payload + return {} + + +def _safe_state_from_payload(payload: Dict[str, Any], *, session_id: str, step_index: int) -> NarrativeState: + state_payload = dict(payload or {}) + state_payload.setdefault("state_id", f"{session_id}:{step_index}") + state_payload.setdefault("world_id", "") + state_payload.setdefault("turn_index", step_index) + state_payload.setdefault("story_phase", "setup") + state_payload.setdefault("chapter_index", step_index) + state_payload.setdefault("min_end_turn", 8) + state_payload.setdefault("fate_pressure", 0.0) + state_payload.setdefault("karmic_weather", {}) + state_payload.setdefault("unresolved_debts", []) + state_payload.setdefault("world_facts", []) + state_payload.setdefault("timeline", []) + state_payload.setdefault("characters", {}) + state_payload.setdefault("relationship_graph", []) + state_payload.setdefault("open_promises", []) + state_payload.setdefault("tension", 0.0) + state_payload.setdefault("themes", {}) + state_payload.setdefault("player_intent", {}) + state_payload.setdefault("recent_scene_functions", []) + state_payload.setdefault("visited_event_ids", []) + state_payload.setdefault("route_fingerprint", []) + state_payload.setdefault("rating_ceiling", "R") + return NarrativeState.from_dict(state_payload) + + +def _step_record_from_replay_payload(payload: Dict[str, Any]) -> Optional[StepRecord]: + if not payload: + return None + session_id = str(payload.get("session_id") or "") + step_index = int(payload.get("step_index") or payload.get("chapter_index") or 0) + if not session_id or step_index <= 0: + return None + return StepRecord( + session_id=session_id, + step_index=step_index, + player_input=str(payload.get("player_input") or ""), + intent_vector={key: float(value) for key, value in dict(payload.get("intent_vector") or {}).items()}, + candidate_batch=CandidateBatch( + raw_candidates=[], + legal_candidates=[], + illegal_candidate_reasons={}, + debug=dict(payload.get("candidate_batch_debug") or {}), + ), + scored_candidates=[], + routes=[], + chosen_event=EventAtom.from_dict(payload["chosen_event"]) if payload.get("chosen_event") else None, + chapter_plan=ChapterPlan.from_dict(payload["chapter_plan"]) if payload.get("chapter_plan") else None, + scene_beats=[SceneBeat.from_dict(item) for item in list(payload.get("scene_beats") or [])], + scene_render_spec=SceneRenderSpec.from_dict(payload["scene_render_spec"]) if payload.get("scene_render_spec") else None, + rendered_scene=RenderedScene.from_dict(payload["rendered_scene"]) if payload.get("rendered_scene") else None, + reader_view=NarrativeViewModel.from_dict(payload["reader_view"]) if payload.get("reader_view") else None, + state_before=_safe_state_from_payload(dict(payload.get("state_before") or {}), session_id=session_id, step_index=max(0, step_index - 1)), + state_after=_safe_state_from_payload(dict(payload.get("state_after") or {}), session_id=session_id, step_index=step_index), + critic_trace=[dict(item) for item in list(payload.get("critic_trace") or [])], + promise_ledger_snapshot=[ + PromiseLedgerEntry.from_dict(item) + for item in list(payload.get("promise_ledger_snapshot") or []) + ], + created_at=str(payload.get("created_at") or ""), + metadata=dict(payload.get("metadata") or {}), + ) -DEFAULT_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///narrativeos_beta.db") + +def _default_database_url() -> str: + configured = str(os.getenv("DATABASE_URL", "") or "").strip() + if configured: + return configured + if os.getenv("VERCEL"): + return "sqlite:////tmp/narrativeos_beta.db" + return "sqlite:///narrativeos_beta.db" + + +DEFAULT_DATABASE_URL = _default_database_url() CONTINUATION_STALE_WINDOW_HOURS = 24 CONTINUATION_TARGET_SAMPLES_PER_WORLD = 12 CONTINUATION_TARGET_SAMPLES_PER_VERSION = 8 CONTINUATION_TARGET_NEGATIVE_SAMPLES = 2 +def _ops_review_item_payload(row: OpsReviewItemRow) -> Dict[str, Any]: + return { + "review_item_id": row.review_item_id, + "source_type": row.source_type, + "source_id": row.source_id, + "queue": row.queue, + "status": row.status, + "severity": row.severity, + "priority": row.priority, + "owner_id": row.owner_id, + "reviewer_id": row.reviewer_id, + "account_id": row.account_id, + "world_id": row.world_id, + "world_version_id": row.world_version_id, + "headline": row.headline, + "summary": row.summary, + "recommended_action": row.recommended_action, + "due_at": row.due_at, + "sla_bucket": row.sla_bucket, + "allowed_actions": list(row.allowed_actions_json or []), + "linked_entities": list(row.linked_entities_json or []), + "source_updated_at": row.source_updated_at, + "last_synced_at": row.last_synced_at, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _quality_policy_payload(row: QualityPolicyRow) -> Dict[str, Any]: + return { + "policy_id": row.policy_id, + "version": row.version, + "scenario_id": row.scenario_id, + "risk_tier": row.risk_tier, + "mode": row.mode, + "rule_ids": list(row.rule_ids_json or []), + "policy_payload": dict(row.policy_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _ops_config_payload(row: OpsConfigRow) -> Dict[str, Any]: + return { + "ops_config_id": row.ops_config_id, + "config_type": row.config_type, + "scope_key": row.scope_key, + "status": row.status, + "config_payload": dict(row.config_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _plan_payload(row: PlanRow) -> Dict[str, Any]: + return { + "plan_id": row.plan_id, + "display_name": row.display_name, + "subscription_tier": row.subscription_tier, + "monthly_price_usd": row.monthly_price_usd, + "status": row.status, + "seat_limit": row.seat_limit, + "workspace_limit": row.workspace_limit, + "campaign_limit": row.campaign_limit, + "plan_payload": dict(row.plan_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _customer_account_payload(row: CustomerAccountRow) -> Dict[str, Any]: + return { + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "display_name": row.display_name, + "status": row.status, + "plan_id": row.plan_id, + "seat_limit": row.seat_limit, + "workspace_limit": row.workspace_limit, + "campaign_limit": row.campaign_limit, + "seat_count": row.seat_count, + "workspace_count": row.workspace_count, + "campaign_count": row.campaign_count, + "renewal_due_at": row.renewal_due_at, + "metadata_json": dict(row.metadata_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _billing_profile_payload(row: BillingProfileRow) -> Dict[str, Any]: + return { + "billing_profile_id": row.billing_profile_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "provider": row.provider, + "provider_customer_ref": row.provider_customer_ref, + "invoice_email": row.invoice_email, + "legal_name": row.legal_name, + "billing_country": row.billing_country, + "tax_status": row.tax_status, + "status": row.status, + "profile_payload_json": dict(row.profile_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _usage_ledger_payload(row: UsageLedgerRow) -> Dict[str, Any]: + return { + "usage_ledger_id": row.usage_ledger_id, + "account_id": row.account_id, + "customer_account_id": row.customer_account_id, + "plan_id": row.plan_id, + "status": row.status, + "billing_period_start": row.billing_period_start, + "billing_period_end": row.billing_period_end, + "presented_count": row.presented_count, + "handoff_count": row.handoff_count, + "conversion_count": row.conversion_count, + "subtotal_amount_usd": row.subtotal_amount_usd, + "disputed_amount_usd": row.disputed_amount_usd, + "credited_amount_usd": row.credited_amount_usd, + "reversed_amount_usd": row.reversed_amount_usd, + "ledger_payload_json": dict(row.ledger_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _billable_event_payload(row: BillableEventRow) -> Dict[str, Any]: + return { + "billable_event_id": row.billable_event_id, + "usage_ledger_id": row.usage_ledger_id, + "account_id": row.account_id, + "customer_account_id": row.customer_account_id, + "plan_id": row.plan_id, + "billable_metric": row.billable_metric, + "status": row.status, + "trace_id": row.trace_id, + "quality_event_id": row.quality_event_id, + "runtime_receipt_event_id": row.runtime_receipt_event_id, + "feedback_item_id": row.feedback_item_id, + "source_surface": row.source_surface, + "world_version_id": row.world_version_id, + "session_id": row.session_id, + "quantity": row.quantity, + "unit_price_usd": row.unit_price_usd, + "amount_usd": row.amount_usd, + "reason_codes_json": list(row.reason_codes_json or []), + "event_payload_json": dict(row.event_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _invoice_preview_payload(row: InvoicePreviewRow) -> Dict[str, Any]: + return { + "invoice_preview_id": row.invoice_preview_id, + "usage_ledger_id": row.usage_ledger_id, + "account_id": row.account_id, + "customer_account_id": row.customer_account_id, + "plan_id": row.plan_id, + "status": row.status, + "billing_period_start": row.billing_period_start, + "billing_period_end": row.billing_period_end, + "subtotal_amount_usd": row.subtotal_amount_usd, + "credits_applied_usd": row.credits_applied_usd, + "disputed_amount_usd": row.disputed_amount_usd, + "credited_amount_usd": row.credited_amount_usd, + "reversed_amount_usd": row.reversed_amount_usd, + "total_due_usd": row.total_due_usd, + "line_items_json": list(row.line_items_json or []), + "summary_json": dict(row.summary_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _credit_balance_payload(row: CreditBalanceRow) -> Dict[str, Any]: + return { + "credit_balance_id": row.credit_balance_id, + "account_id": row.account_id, + "customer_account_id": row.customer_account_id, + "balance_type": row.balance_type, + "amount_usd": row.amount_usd, + "source_ref_json": dict(row.source_ref_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _overage_flag_payload(row: OverageFlagRow) -> Dict[str, Any]: + return { + "overage_flag_id": row.overage_flag_id, + "account_id": row.account_id, + "customer_account_id": row.customer_account_id, + "plan_id": row.plan_id, + "metric_type": row.metric_type, + "status": row.status, + "observed_units": row.observed_units, + "included_units": row.included_units, + "overage_units": row.overage_units, + "flag_payload_json": dict(row.flag_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _campaign_payload(row: CampaignRow) -> Dict[str, Any]: + return { + "campaign_id": row.campaign_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "title": row.title, + "target_icp_vertical": row.target_icp_vertical, + "cta_text": row.cta_text, + "disclosure_text": row.disclosure_text, + "activation_status": row.activation_status, + "selected_channels_json": list(row.selected_channels_json or []), + "selected_partner_refs_json": list(row.selected_partner_refs_json or []), + "primary_review_case_id": row.primary_review_case_id, + "latest_submission_id": row.latest_submission_id, + "campaign_payload_json": dict(row.campaign_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _campaign_proof_bundle_payload(row: CampaignProofBundleRow) -> Dict[str, Any]: + return { + "proof_bundle_id": row.proof_bundle_id, + "campaign_id": row.campaign_id, + "bundle_label": row.bundle_label, + "proof_points_json": list(row.proof_points_json or []), + "source_urls_json": list(row.source_urls_json or []), + "artifact_refs_json": list(row.artifact_refs_json or []), + "bundle_payload_json": dict(row.bundle_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _campaign_channel_target_payload(row: CampaignChannelTargetRow) -> Dict[str, Any]: + return { + "channel_target_id": row.channel_target_id, + "campaign_id": row.campaign_id, + "channel_name": row.channel_name, + "partner_ref": row.partner_ref, + "priority": row.priority, + "readiness_status": row.readiness_status, + "target_payload_json": dict(row.target_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _campaign_review_submission_payload(row: CampaignReviewSubmissionRow) -> Dict[str, Any]: + return { + "submission_id": row.submission_id, + "campaign_id": row.campaign_id, + "review_case_id": row.review_case_id, + "status": row.status, + "submitted_by": row.submitted_by, + "reviewer_id": row.reviewer_id, + "decision_note": row.decision_note, + "submitted_at": row.submitted_at, + "decided_at": row.decided_at, + "submission_payload_json": dict(row.submission_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _partner_payload(row: PartnerRow) -> Dict[str, Any]: + return { + "partner_id": row.partner_id, + "name": row.name, + "lifecycle_status": row.lifecycle_status, + "sla_status": row.sla_status, + "receipt_capability": row.receipt_capability, + "disclosure_readiness": row.disclosure_readiness, + "billing_readiness": row.billing_readiness, + "allowlisted_channels_json": list(row.allowlisted_channels_json or []), + "primary_endpoint_url": row.primary_endpoint_url, + "endpoint_health_status": row.endpoint_health_status, + "partner_payload_json": dict(row.partner_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _partner_capability_payload(row: PartnerCapabilityRow) -> Dict[str, Any]: + return { + "partner_capability_id": row.partner_capability_id, + "partner_id": row.partner_id, + "capability_type": row.capability_type, + "status": row.status, + "capability_value": row.capability_value, + "capability_payload_json": dict(row.capability_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _partner_health_check_payload(row: PartnerHealthCheckRow) -> Dict[str, Any]: + return { + "health_check_id": row.health_check_id, + "partner_id": row.partner_id, + "endpoint_url": row.endpoint_url, + "status": row.status, + "status_code": row.status_code, + "response_time_ms": row.response_time_ms, + "checked_at": row.checked_at, + "health_payload_json": dict(row.health_payload_json or {}), + "created_at": row.created_at, + } + + +def _dispute_payload(row: DisputeRow) -> Dict[str, Any]: + return { + "dispute_id": row.dispute_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "campaign_id": row.campaign_id, + "invoice_preview_id": row.invoice_preview_id, + "billable_event_id": row.billable_event_id, + "quality_event_id": row.quality_event_id, + "trace_id": row.trace_id, + "dispute_reason_code": row.dispute_reason_code, + "note": row.note, + "status": row.status, + "requested_amount_usd": row.requested_amount_usd, + "resolved_amount_usd": row.resolved_amount_usd, + "requested_by": row.requested_by, + "reviewer_id": row.reviewer_id, + "resolution_note": row.resolution_note, + "dispute_payload_json": dict(row.dispute_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _refund_request_payload(row: RefundRequestRow) -> Dict[str, Any]: + return { + "refund_request_id": row.refund_request_id, + "dispute_id": row.dispute_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "invoice_preview_id": row.invoice_preview_id, + "billable_event_id": row.billable_event_id, + "trace_id": row.trace_id, + "status": row.status, + "requested_amount_usd": row.requested_amount_usd, + "approved_amount_usd": row.approved_amount_usd, + "requested_by": row.requested_by, + "reviewer_id": row.reviewer_id, + "refund_payload_json": dict(row.refund_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _settlement_run_payload(row: SettlementRunRow) -> Dict[str, Any]: + return { + "settlement_run_id": row.settlement_run_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "billing_period_start": row.billing_period_start, + "billing_period_end": row.billing_period_end, + "status": row.status, + "subtotal_amount_usd": row.subtotal_amount_usd, + "disputed_amount_usd": row.disputed_amount_usd, + "credited_amount_usd": row.credited_amount_usd, + "reversed_amount_usd": row.reversed_amount_usd, + "refunded_amount_usd": row.refunded_amount_usd, + "net_amount_usd": row.net_amount_usd, + "run_payload_json": dict(row.run_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _settlement_item_payload(row: SettlementItemRow) -> Dict[str, Any]: + return { + "settlement_item_id": row.settlement_item_id, + "settlement_run_id": row.settlement_run_id, + "billable_event_id": row.billable_event_id, + "invoice_preview_id": row.invoice_preview_id, + "dispute_id": row.dispute_id, + "refund_request_id": row.refund_request_id, + "status": row.status, + "amount_usd": row.amount_usd, + "item_payload_json": dict(row.item_payload_json or {}), + "created_at": row.created_at, + } + + +def _support_case_payload(row: SupportCaseRow) -> Dict[str, Any]: + return { + "support_case_id": row.support_case_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "campaign_id": row.campaign_id, + "invoice_preview_id": row.invoice_preview_id, + "billable_event_id": row.billable_event_id, + "quality_event_id": row.quality_event_id, + "trace_id": row.trace_id, + "case_type": row.case_type, + "subject": row.subject, + "description": row.description, + "status": row.status, + "priority": row.priority, + "requested_by": row.requested_by, + "owner_id": row.owner_id, + "resolution_note": row.resolution_note, + "support_payload_json": dict(row.support_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _manual_adjustment_payload(row: ManualAdjustmentRow) -> Dict[str, Any]: + return { + "adjustment_id": row.adjustment_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "dispute_id": row.dispute_id, + "refund_request_id": row.refund_request_id, + "invoice_preview_id": row.invoice_preview_id, + "billable_event_id": row.billable_event_id, + "adjustment_type": row.adjustment_type, + "amount_usd": row.amount_usd, + "status": row.status, + "requested_by": row.requested_by, + "reviewer_id": row.reviewer_id, + "adjustment_payload_json": dict(row.adjustment_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _audit_log_payload(row: AuditLogRow) -> Dict[str, Any]: + return { + "audit_log_id": row.audit_log_id, + "actor_id": row.actor_id, + "actor_role": row.actor_role, + "account_id": row.account_id, + "customer_account_id": row.customer_account_id, + "object_type": row.object_type, + "object_id": row.object_id, + "action_type": row.action_type, + "source_surface": row.source_surface, + "customer_visible_payload_json": dict(row.customer_visible_payload_json or {}), + "internal_payload_json": dict(row.internal_payload_json or {}), + "created_at": row.created_at, + } + + +def _customer_audit_export_payload(row: CustomerAuditExportRow) -> Dict[str, Any]: + return { + "audit_export_id": row.audit_export_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "requested_by": row.requested_by, + "period_start": row.period_start, + "period_end": row.period_end, + "export_payload_json": dict(row.export_payload_json or {}), + "created_at": row.created_at, + } + + +def _data_retention_policy_payload(row: DataRetentionPolicyRow) -> Dict[str, Any]: + return { + "retention_policy_id": row.retention_policy_id, + "scope": row.scope, + "retention_days": row.retention_days, + "deletion_mode": row.deletion_mode, + "status": row.status, + "policy_payload_json": dict(row.policy_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _data_deletion_request_payload(row: DataDeletionRequestRow) -> Dict[str, Any]: + return { + "deletion_request_id": row.deletion_request_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "requested_by": row.requested_by, + "scope": row.scope, + "status": row.status, + "requested_payload_json": dict(row.requested_payload_json or {}), + "affected_object_counts_json": dict(row.affected_object_counts_json or {}), + "resolution_note": row.resolution_note, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _invoice_issuance_payload(row: InvoiceIssuanceRow) -> Dict[str, Any]: + return { + "invoice_id": row.invoice_id, + "invoice_preview_id": row.invoice_preview_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "provider": row.provider, + "provider_invoice_ref": row.provider_invoice_ref, + "provider_customer_ref": row.provider_customer_ref, + "status": row.status, + "currency": row.currency, + "subtotal_amount_usd": row.subtotal_amount_usd, + "total_due_usd": row.total_due_usd, + "hosted_invoice_url": row.hosted_invoice_url, + "invoice_pdf_url": row.invoice_pdf_url, + "issued_at": row.issued_at, + "paid_at": row.paid_at, + "voided_at": row.voided_at, + "invoice_payload_json": dict(row.invoice_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _payment_transaction_payload(row: PaymentTransactionRow) -> Dict[str, Any]: + return { + "payment_transaction_id": row.payment_transaction_id, + "invoice_id": row.invoice_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "provider": row.provider, + "provider_transaction_ref": row.provider_transaction_ref, + "transaction_type": row.transaction_type, + "status": row.status, + "amount_usd": row.amount_usd, + "currency": row.currency, + "trace_id": row.trace_id, + "transaction_payload_json": dict(row.transaction_payload_json or {}), + "occurred_at": row.occurred_at, + "created_at": row.created_at, + } + + +def _provider_webhook_event_payload(row: ProviderWebhookEventRow) -> Dict[str, Any]: + return { + "provider_webhook_event_id": row.provider_webhook_event_id, + "provider": row.provider, + "provider_event_id": row.provider_event_id, + "event_type": row.event_type, + "status": row.status, + "invoice_id": row.invoice_id, + "account_id": row.account_id, + "payload_json": dict(row.payload_json or {}), + "processing_result_json": dict(row.processing_result_json or {}), + "created_at": row.created_at, + "processed_at": row.processed_at, + } + + +def _credit_note_payload(row: CreditNoteRow) -> Dict[str, Any]: + return { + "credit_note_id": row.credit_note_id, + "invoice_id": row.invoice_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "provider": row.provider, + "provider_credit_note_ref": row.provider_credit_note_ref, + "status": row.status, + "amount_usd": row.amount_usd, + "reason": row.reason, + "credit_payload_json": dict(row.credit_payload_json or {}), + "created_at": row.created_at, + } + + +def _payment_retry_attempt_payload(row: PaymentRetryAttemptRow) -> Dict[str, Any]: + return { + "payment_retry_attempt_id": row.payment_retry_attempt_id, + "invoice_id": row.invoice_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "provider": row.provider, + "status": row.status, + "retry_reason": row.retry_reason, + "attempt_count": row.attempt_count, + "next_retry_at": row.next_retry_at, + "retry_payload_json": dict(row.retry_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _dunning_event_payload(row: DunningEventRow) -> Dict[str, Any]: + return { + "dunning_event_id": row.dunning_event_id, + "invoice_id": row.invoice_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "status": row.status, + "step": row.step, + "event_payload_json": dict(row.event_payload_json or {}), + "created_at": row.created_at, + } + + +def _renewal_tracker_payload(row: RenewalTrackerRow) -> Dict[str, Any]: + return { + "renewal_tracker_id": row.renewal_tracker_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "status": row.status, + "renewal_due_at": row.renewal_due_at, + "tracker_payload_json": dict(row.tracker_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _dunning_run_payload(row: DunningRunRow) -> Dict[str, Any]: + return { + "dunning_run_id": row.dunning_run_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "invoice_id": row.invoice_id, + "status": row.status, + "current_step": row.current_step, + "dunning_payload_json": dict(row.dunning_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _pilot_conversion_track_payload(row: PilotConversionTrackRow) -> Dict[str, Any]: + return { + "pilot_conversion_track_id": row.pilot_conversion_track_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "status": row.status, + "track_payload_json": dict(row.track_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _expansion_candidate_payload(row: ExpansionCandidateRow) -> Dict[str, Any]: + return { + "expansion_candidate_id": row.expansion_candidate_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "status": row.status, + "trigger_type": row.trigger_type, + "candidate_payload_json": dict(row.candidate_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _churn_risk_flag_payload(row: ChurnRiskFlagRow) -> Dict[str, Any]: + return { + "churn_risk_flag_id": row.churn_risk_flag_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "status": row.status, + "risk_level": row.risk_level, + "flag_payload_json": dict(row.flag_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _production_signoff_payload(row: ProductionSignoffRow) -> Dict[str, Any]: + return { + "signoff_id": row.signoff_id, + "launch_label": row.launch_label, + "status": row.status, + "source_go_live_checklist_id": row.source_go_live_checklist_id, + "source_manual_signoff_bundle_id": row.source_manual_signoff_bundle_id, + "rollup_summary_json": dict(row.rollup_summary_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _production_signoff_item_payload(row: ProductionSignoffItemRow) -> Dict[str, Any]: + return { + "signoff_item_id": row.signoff_item_id, + "signoff_id": row.signoff_id, + "item_code": row.item_code, + "category": row.category, + "label": row.label, + "owner_role": row.owner_role, + "owner_actor_id": row.owner_actor_id, + "due_at": row.due_at, + "status": row.status, + "decision_note": row.decision_note, + "approved_at": row.approved_at, + "evidence_count": int(row.evidence_count or 0), + "item_payload_json": dict(row.item_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _production_signoff_evidence_payload(row: ProductionSignoffEvidenceRow) -> Dict[str, Any]: + return { + "evidence_id": row.evidence_id, + "signoff_id": row.signoff_id, + "signoff_item_id": row.signoff_item_id, + "evidence_type": row.evidence_type, + "source_ref_json": dict(row.source_ref_json or {}), + "summary": row.summary, + "customer_safe": bool(row.customer_safe), + "payload_json": dict(row.payload_json or {}), + "created_at": row.created_at, + } + + +def _production_cutover_window_payload(row: ProductionCutoverWindowRow) -> Dict[str, Any]: + return { + "cutover_window_id": row.cutover_window_id, + "signoff_id": row.signoff_id, + "launch_wave": row.launch_wave, + "target_environment": row.target_environment, + "starts_at": row.starts_at, + "ends_at": row.ends_at, + "rollback_owner_role": row.rollback_owner_role, + "status": row.status, + "cutover_payload_json": dict(row.cutover_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _production_customer_acceptance_record_payload(row: ProductionCustomerAcceptanceRecordRow) -> Dict[str, Any]: + return { + "acceptance_record_id": row.acceptance_record_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "signoff_id": row.signoff_id, + "launch_wave": row.launch_wave, + "status": row.status, + "readiness_summary_json": dict(row.readiness_summary_json or {}), + "acceptance_payload_json": dict(row.acceptance_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _go_live_ready_account_payload(row: GoLiveReadyAccountRow) -> Dict[str, Any]: + return { + "go_live_ready_account_id": row.go_live_ready_account_id, + "customer_account_id": row.customer_account_id, + "account_id": row.account_id, + "acceptance_record_id": row.acceptance_record_id, + "launch_wave": row.launch_wave, + "status": row.status, + "readiness_payload_json": dict(row.readiness_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _launch_wave_status_payload(row: LaunchWaveStatusRow) -> Dict[str, Any]: + return { + "launch_wave_status_id": row.launch_wave_status_id, + "launch_wave": row.launch_wave, + "status": row.status, + "target_environment": row.target_environment, + "wave_payload_json": dict(row.wave_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _production_preflight_run_payload(row: ProductionPreflightRunRow) -> Dict[str, Any]: + return { + "preflight_run_id": row.preflight_run_id, + "signoff_id": row.signoff_id, + "launch_wave": row.launch_wave, + "target_environment": row.target_environment, + "status": row.status, + "go_no_go": row.go_no_go, + "hard_fail_count": int(row.hard_fail_count or 0), + "soft_fail_count": int(row.soft_fail_count or 0), + "run_payload_json": dict(row.run_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _production_preflight_check_payload(row: ProductionPreflightCheckRow) -> Dict[str, Any]: + return { + "preflight_check_id": row.preflight_check_id, + "preflight_run_id": row.preflight_run_id, + "check_key": row.check_key, + "linked_signoff_item_code": row.linked_signoff_item_code, + "owner_role": row.owner_role, + "status": row.status, + "summary": row.summary, + "evidence_ref": row.evidence_ref, + "payload_json": dict(row.payload_json or {}), + "created_at": row.created_at, + } + + +def _first_7_day_outcome_payload(row: First7DayOutcomeRow) -> Dict[str, Any]: + return { + "first_7_day_outcome_id": row.first_7_day_outcome_id, + "account_id": row.account_id, + "customer_account_id": row.customer_account_id, + "launch_wave": row.launch_wave, + "launch_anchor_at": row.launch_anchor_at, + "outcome_payload_json": dict(row.outcome_payload_json or {}), + "generated_at": row.generated_at, + } + + +def _first_30_day_value_summary_payload(row: First30DayValueSummaryRow) -> Dict[str, Any]: + return { + "first_30_day_value_summary_id": row.first_30_day_value_summary_id, + "account_id": row.account_id, + "customer_account_id": row.customer_account_id, + "launch_wave": row.launch_wave, + "launch_anchor_at": row.launch_anchor_at, + "provisional": bool(row.provisional), + "summary_payload_json": dict(row.summary_payload_json or {}), + "generated_at": row.generated_at, + } + + +def _pilot_to_paid_readiness_score_payload(row: PilotToPaidReadinessScoreRow) -> Dict[str, Any]: + return { + "pilot_to_paid_readiness_score_id": row.pilot_to_paid_readiness_score_id, + "account_id": row.account_id, + "customer_account_id": row.customer_account_id, + "launch_wave": row.launch_wave, + "launch_anchor_at": row.launch_anchor_at, + "score": float(row.score or 0.0), + "band": row.band, + "score_payload_json": dict(row.score_payload_json or {}), + "generated_at": row.generated_at, + } + + +def _customer_success_snapshot_payload(row: CustomerSuccessSnapshotRow) -> Dict[str, Any]: + return { + "customer_success_snapshot_id": row.customer_success_snapshot_id, + "account_id": row.account_id, + "customer_account_id": row.customer_account_id, + "launch_wave": row.launch_wave, + "launch_anchor_at": row.launch_anchor_at, + "snapshot_payload_json": dict(row.snapshot_payload_json or {}), + "generated_at": row.generated_at, + } + + +def _library_stats_cube_payload(row: LibraryStatsCubeRow) -> Dict[str, Any]: + return { + "library_stats_cube_id": row.library_stats_cube_id, + "account_id": row.account_id, + "semantic_version": row.semantic_version, + "snapshot_payload_json": dict(row.snapshot_payload_json or {}), + "source_breakdown_json": dict(row.source_breakdown_json or {}), + "source_updated_at": row.source_updated_at, + "invalidated_at": row.invalidated_at, + "last_invalidated_event_name": row.last_invalidated_event_name, + "last_invalidated_event_at": row.last_invalidated_event_at, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _production_launch_event_payload(row: ProductionLaunchEventRow) -> Dict[str, Any]: + return { + "launch_event_id": row.launch_event_id, + "launch_wave": row.launch_wave, + "account_id": row.account_id, + "event_category": row.event_category, + "event_type": row.event_type, + "phase": row.phase, + "severity": row.severity, + "related_object_type": row.related_object_type, + "related_object_id": row.related_object_id, + "occurred_at": row.occurred_at, + "event_payload_json": dict(row.event_payload_json or {}), + "created_at": row.created_at, + } + + +def _production_postmortem_record_payload(row: ProductionPostmortemRecordRow) -> Dict[str, Any]: + return { + "postmortem_record_id": row.postmortem_record_id, + "launch_wave": row.launch_wave, + "account_id": row.account_id, + "status": row.status, + "summary_json": dict(row.summary_json or {}), + "generated_at": row.generated_at, + } + + +def _go_live_day_run_payload(row: GoLiveDayRunRow) -> Dict[str, Any]: + return { + "go_live_day_run_id": row.go_live_day_run_id, + "signoff_id": row.signoff_id, + "launch_wave": row.launch_wave, + "account_id": row.account_id, + "status": row.status, + "activation_state_before": row.activation_state_before, + "activation_state_after": row.activation_state_after, + "report_payload_json": dict(row.report_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _go_live_day_checkpoint_payload(row: GoLiveDayCheckpointRow) -> Dict[str, Any]: + return { + "go_live_day_checkpoint_id": row.go_live_day_checkpoint_id, + "go_live_day_run_id": row.go_live_day_run_id, + "checkpoint_key": row.checkpoint_key, + "status": row.status, + "summary": row.summary, + "evidence_ref": row.evidence_ref, + "rollback_recommendation": row.rollback_recommendation, + "checkpoint_payload_json": dict(row.checkpoint_payload_json or {}), + "created_at": row.created_at, + } + + +def _launch_week_guard_run_payload(row: LaunchWeekGuardRunRow) -> Dict[str, Any]: + return { + "launch_week_guard_run_id": row.launch_week_guard_run_id, + "launch_wave": row.launch_wave, + "account_id": row.account_id, + "status": row.status, + "replication_readiness": row.replication_readiness, + "summary_json": dict(row.summary_json or {}), + "generated_at": row.generated_at, + } + + +def _first_customer_success_pack_payload(row: FirstCustomerSuccessPackRow) -> Dict[str, Any]: + return { + "first_customer_success_pack_id": row.first_customer_success_pack_id, + "launch_wave": row.launch_wave, + "account_id": row.account_id, + "status": row.status, + "pack_payload_json": dict(row.pack_payload_json or {}), + "generated_at": row.generated_at, + } + + +def _quality_event_payload(row: QualityEventRow) -> Dict[str, Any]: + return { + "event_id": row.event_id, + "trace_id": row.trace_id, + "event_type": row.event_type, + "source_surface": row.source_surface, + "status": row.status, + "world_version_id": row.world_version_id, + "session_id": row.session_id, + "source_ref": dict(row.source_ref_json or {}), + "payload": dict(row.payload_json or {}), + "created_at": row.created_at, + } + + +def _content_quality_score_payload(row: ContentQualityScoreRow) -> Dict[str, Any]: + return { + "score_id": row.score_id, + "trace_id": row.trace_id, + "source_surface": row.source_surface, + "status": row.status, + "world_version_id": row.world_version_id, + "session_id": row.session_id, + "chapter_id": row.chapter_id, + "rubric_version": row.rubric_version, + "overall_score": float(row.overall_score or 0.0), + "veto": bool(row.veto), + "dimension_scores": dict(row.dimension_scores_json or {}), + "reason_codes": list(row.reason_codes_json or []), + "evidence_refs": list(row.evidence_refs_json or []), + "score_payload": dict(row.score_payload_json or {}), + "created_at": row.created_at, + } + + +def _review_case_payload(row: ReviewCaseRow) -> Dict[str, Any]: + return { + "case_id": row.case_id, + "trace_id": row.trace_id, + "case_type": row.case_type, + "status": row.status, + "owner_id": row.owner_id, + "source_surface": row.source_surface, + "world_version_id": row.world_version_id, + "session_id": row.session_id, + "score_id": row.score_id, + "source_ref": dict(row.source_ref_json or {}), + "reason_codes": list(row.reason_codes_json or []), + "evidence_refs": list(row.evidence_refs_json or []), + "case_payload": dict(row.case_payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _quality_feedback_item_payload(row: QualityFeedbackItemRow) -> Dict[str, Any]: + return { + "feedback_item_id": row.feedback_item_id, + "trace_id": row.trace_id, + "source_event_id": row.source_event_id, + "feedback_type": row.feedback_type, + "signal": row.signal, + "source_surface": row.source_surface, + "account_id": row.account_id, + "world_version_id": row.world_version_id, + "session_id": row.session_id, + "chapter_id": row.chapter_id, + "source_ref": dict(row.source_ref_json or {}), + "payload": dict(row.payload_json or {}), + "created_at": row.created_at, + } + + +def _grounding_check_payload(row: GroundingCheckRow) -> Dict[str, Any]: + return { + "grounding_check_id": row.grounding_check_id, + "trace_id": row.trace_id, + "status": row.status, + "confidence": float(row.confidence or 0.0), + "source_surface": row.source_surface, + "world_version_id": row.world_version_id, + "session_id": row.session_id, + "chapter_id": row.chapter_id, + "evidence_refs": list(row.evidence_refs_json or []), + "unsupported_claims": list(row.unsupported_claims_json or []), + "reason_codes": list(row.reason_codes_json or []), + "summary": row.summary, + "created_at": row.created_at, + } + + +def _author_work_payload(row: AuthorWorkRow) -> Dict[str, Any]: + root_work_id = row.root_work_id or row.work_id + branch_id = row.branch_id or row.work_id + branch_kind = row.branch_kind or ("mainline" if root_work_id == row.work_id else "parallel_universe") + branch_name = row.branch_name or ("主线" if root_work_id == row.work_id else "平行宇宙") + if row.is_active_line is None: + is_active_line = bool(root_work_id == row.work_id) + else: + is_active_line = bool(row.is_active_line) + return { + "work_id": row.work_id, + "world_version_id": row.world_version_id, + "account_id": row.account_id, + "title": row.title, + "status": row.status, + "current_revision": row.current_revision, + "chapter_count": row.chapter_count, + "target_chapter_count": row.target_chapter_count, + "branch_id": branch_id, + "root_work_id": root_work_id, + "parent_work_id": row.parent_work_id, + "branch_name": branch_name, + "branch_kind": branch_kind, + "branch_origin_label": row.branch_origin_label, + "fork_after_chapter_index": int(row.fork_after_chapter_index or 0), + "is_active_line": is_active_line, + "narrative_state_json": dict(row.narrative_state_json or {}), + "diagnostics_summary_json": dict(row.diagnostics_summary_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _author_work_chapter_payload(row: AuthorWorkChapterRow) -> Dict[str, Any]: + return { + "chapter_record_id": row.chapter_record_id, + "work_id": row.work_id, + "chapter_index": row.chapter_index, + "chapter_title": row.chapter_title, + "body": row.body, + "status": row.status, + "source_type": row.source_type, + "summary": row.summary, + "diagnostic_summary_json": dict(row.diagnostic_summary_json or {}), + "chapter_task_json": dict(row.chapter_task_json or {}), + "choices_json": list(row.choices_json or []), + "state_snapshot_json": dict(row.state_snapshot_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _route_choice_payload(row: RouteChoiceRow) -> Dict[str, Any]: + return { + "choice_event_id": int(row.choice_event_id), + "session_id": row.session_id, + "chapter_id": row.chapter_id, + "choice_id": row.choice_id, + "selected_at": row.selected_at, + "payload_json": dict(row.payload_json or {}), + } + + +def _author_work_revision_payload(row: AuthorWorkRevisionRow) -> Dict[str, Any]: + return { + "revision_id": row.revision_id, + "work_id": row.work_id, + "revision_type": row.revision_type, + "summary": row.summary, + "snapshot_json": dict(row.snapshot_json or {}), + "created_at": row.created_at, + } + + +def _soul_profile_preference_payload(row: SoulProfilePreferenceRow) -> Dict[str, Any]: + return { + "actor_id": row.actor_id, + "account_id": row.account_id, + "genres": list(row.genres_json or []), + "styles": list(row.styles_json or []), + "privacy_mode": row.privacy_mode, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _library_work_favorite_payload(row: LibraryWorkFavoriteRow) -> Dict[str, Any]: + return { + "favorite_id": row.favorite_id, + "account_id": row.account_id, + "work_id": row.work_id, + "work_kind": row.work_kind, + "title_snapshot": row.title_snapshot, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _library_follow_payload(row: LibraryFollowRow) -> Dict[str, Any]: + return { + "follow_id": row.follow_id, + "account_id": row.account_id, + "target_type": row.target_type, + "target_id": row.target_id, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _showcase_work_view_payload(row: ShowcaseWorkViewRow) -> Dict[str, Any]: + return { + "showcase_view_id": row.showcase_view_id, + "world_id": row.world_id, + "world_version_id": row.world_version_id, + "account_id": row.account_id, + "viewer_key": row.viewer_key, + "event_type": row.event_type, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _generated_media_asset_payload(row: GeneratedMediaAssetRow) -> Dict[str, Any]: + return { + "asset_id": row.asset_id, + "asset_kind": row.asset_kind, + "owner_scope": row.owner_scope, + "owner_id": row.owner_id, + "world_id": row.world_id, + "world_version_id": row.world_version_id, + "session_id": row.session_id, + "chapter_index": row.chapter_index, + "reader_id": row.reader_id, + "storage_bucket": row.storage_bucket, + "storage_key": row.storage_key, + "mime_type": row.mime_type, + "width": row.width, + "height": row.height, + "visibility": row.visibility, + "generation_status": row.generation_status, + "model_name": row.model_name, + "prompt_version": row.prompt_version, + "source_fingerprint": row.source_fingerprint, + "prompt_trace_json": dict(row.prompt_trace_json or {}), + "error": row.error, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _author_project_graph_payload(row: AuthorProjectGraphRow) -> Dict[str, Any]: + return { + "project_id": row.project_id, + "world_version_id": row.world_version_id, + "account_id": row.account_id, + "engine": row.engine, + "enabled_rule_ids": list(row.enabled_rule_ids_json or []), + "nodes": list(row.nodes_json or []), + "connections": list(row.connections_json or []), + "metadata_json": dict(row.metadata_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def _parse_timestamp(value: Optional[str]) -> datetime: if not value: return datetime.fromtimestamp(0, tz=timezone.utc) @@ -105,9 +1632,105 @@ def _continuation_recommended_action( return "coverage_sufficient" +def _correlation_lookup(entries: List[Dict[str, Any]], metric: str) -> Optional[float]: + for item in entries: + if str(item.get("metric") or "") == metric: + return float(item.get("correlation", 0.0) or 0.0) + return None + + +def _calibration_recommendation(*, sample_gap: int, correlation: Optional[float], positive_direction: bool) -> str: + if sample_gap > 0 or correlation is None: + return "insufficient_coverage" + if positive_direction: + return "tighten" if correlation >= 0.15 else "hold" + return "tighten" if correlation <= -0.15 else "hold" + + +def _build_q03_q09_calibration_summary( + correlations: List[Dict[str, Any]], + signal_summary: Dict[str, Any], +) -> Dict[str, Any]: + sample_count = int(signal_summary.get("sample_count", 0) or 0) + sample_gap = int(signal_summary.get("sample_gap", 0) or 0) + coverage_status = "sufficient" if sample_gap <= 0 and sample_count > 0 else "insufficient_coverage" + q03_primary_metric = None + q03_primary_correlation = None + for metric_name in [ + "semantic_paragraph_similarity_score", + "event_coverage_gap_score", + "beat_coverage_gap_score", + "uncovered_event_count", + "uncovered_beat_count", + "overcovered_beat_count", + "q03_present", + "paragraph_similarity_score", + "beat_structure_repetition_score", + "lexical_repetition_score", + "n_gram_repetition_score", + ]: + correlation = _correlation_lookup(correlations, metric_name) + if correlation is None: + continue + if q03_primary_metric is None or abs(correlation) > abs(float(q03_primary_correlation or 0.0)): + q03_primary_metric = metric_name + q03_primary_correlation = correlation + q09_primary_metric = None + q09_primary_correlation = None + for metric_name in ["q09_present", "pacing", "hook_quality"]: + correlation = _correlation_lookup(correlations, metric_name) + if correlation is None: + continue + if q09_primary_metric is None or abs(correlation) > abs(float(q09_primary_correlation or 0.0)): + q09_primary_metric = metric_name + q09_primary_correlation = correlation + return { + "coverage_status": coverage_status, + "sample_count": sample_count, + "sample_gap": sample_gap, + "q03": { + "current_thresholds": dict(LONGFORM_Q03_SIGNAL_THRESHOLDS), + "primary_metric": q03_primary_metric, + "primary_correlation": q03_primary_correlation, + "recommendation": _calibration_recommendation( + sample_gap=sample_gap, + correlation=q03_primary_correlation, + positive_direction=False, + ), + }, + "q09": { + "current_thresholds": { + "pacing_threshold": float(LONGFORM_SOFT_ISSUE_THRESHOLDS["q09_pacing_threshold"]), + "hook_threshold": float(LONGFORM_SOFT_ISSUE_THRESHOLDS["q09_hook_threshold"]), + }, + "primary_metric": q09_primary_metric, + "primary_correlation": q09_primary_correlation, + "recommendation": _calibration_recommendation( + sample_gap=sample_gap, + correlation=q09_primary_correlation, + positive_direction=True if q09_primary_metric in {"pacing", "hook_quality"} else False, + ), + }, + } + + class SQLAlchemyPlatformRepository: def __init__(self, database_url: str = DEFAULT_DATABASE_URL) -> None: - self.engine, self.SessionLocal = create_platform_session_local(database_url) + self.database_url = database_url + self.database_failover_reason = "" + try: + self.engine, self.SessionLocal = create_platform_session_local(database_url) + except SQLAlchemyError as exc: + if not (_is_postgres_database_url(database_url) and _env_enabled(DATABASE_FAILOVER_SQLITE_ENV)): + raise + failover_url = _serverless_sqlite_failover_url() + self.database_url = failover_url + self.database_failover_reason = exc.__class__.__name__ + print( + "NarrativeOS database failover enabled: Postgres connection failed; using serverless SQLite fallback.", + flush=True, + ) + self.engine, self.SessionLocal = create_platform_session_local(failover_url) self.registry = FileSystemWorldRegistry() self._bootstrap_builtin_worldpacks() @@ -205,6 +1828,7 @@ def list_world_versions(self, world_id: Optional[str] = None, status: Optional[s "world_version_id": row.world_version_id, "world_id": row.world_id, "version": row.version, + "author_id": row.author_id, "status": row.status, "risk_rating": row.risk_rating, "title": (row.worldpack_json or {}).get("title", row.world_id), @@ -224,6 +1848,7 @@ def list_worlds(self) -> List[Dict[str, Any]]: latest_worldpack = self.get_world_version(row.latest_version).worldpack_json except KeyError: latest_worldpack = {} + latest_metadata = dict(latest_worldpack.get("metadata") or {}) worlds.append( { "world_id": row.world_id, @@ -234,520 +1859,1624 @@ def list_worlds(self) -> List[Dict[str, Any]]: "risk_rating": (latest_worldpack.get("manifest") or {}).get("risk_rating"), "trial_available": ((latest_worldpack.get("manifest") or {}).get("monetization_policy") or {}).get("trial_chapters", 0) > 0, "access_state": "trial", + "catalog_role": latest_metadata.get("catalog_role"), + "public_catalog_visible": latest_metadata.get("public_catalog_visible"), + "claim_safe_band": latest_metadata.get("claim_safe_band"), + "product_ready_band": latest_metadata.get("product_ready_band"), + "longform_500_product_readiness": dict(latest_metadata.get("longform_500_product_readiness") or {}), "created_at": row.created_at, "updated_at": row.updated_at, } ) return worlds - def get_world(self, world_id: str) -> WorldRecord: + # Author works + def save_author_work(self, work: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + work_id = str(work.get("work_id") or f"work_{uuid4().hex[:12]}") + root_work_id = str(work.get("root_work_id") or work_id) + payload = { + "work_id": work_id, + "world_version_id": str(work["world_version_id"]), + "account_id": str(work["account_id"]), + "title": str(work.get("title") or work["world_version_id"]), + "status": str(work.get("status") or "draft"), + "current_revision": work.get("current_revision"), + "chapter_count": int(work.get("chapter_count", 0) or 0), + "target_chapter_count": int(work.get("target_chapter_count", 0) or 0), + "branch_id": str(work.get("branch_id") or work_id), + "root_work_id": root_work_id, + "parent_work_id": str(work.get("parent_work_id") or "") or None, + "branch_name": str(work.get("branch_name") or ("主线" if root_work_id == work_id else "平行宇宙")), + "branch_kind": str(work.get("branch_kind") or ("mainline" if root_work_id == work_id else "parallel_universe")), + "branch_origin_label": str(work.get("branch_origin_label") or "") or None, + "fork_after_chapter_index": int(work.get("fork_after_chapter_index", 0) or 0), + "is_active_line": 1 if bool(work.get("is_active_line", root_work_id == work_id)) else 0, + "narrative_state_json": dict(work.get("narrative_state_json") or {}), + "diagnostics_summary_json": dict(work.get("diagnostics_summary_json") or {}), + } with self.SessionLocal() as session: - row = session.get(WorldRow, world_id) - if row is None or not row.latest_version: - raise KeyError("unknown_world:%s" % world_id) - runtime = self.get_runtime_bundle(row.latest_version) - return runtime.world_record - - def get_runtime_bundle(self, world_version_id: str) -> RuntimeBundle: - version = self.get_world_version(world_version_id) - try: - return self.registry.get_runtime_bundle(world_version_id) - except KeyError: - return runtime_bundle_from_worldpack_data( - { - "world_version_id": world_version_id, - "world_id": version.world_id, - "status": version.status, - "worldpack": version.worldpack_json, - } - ) - - def create_world(self, world_record: WorldRecord) -> WorldRecord: - worldpack = WorldPack.from_dict(self.registry.get_published_world(world_record.world.world_id)["worldpack"]) if any(card["world_id"] == world_record.world.world_id for card in self.registry.list_worldpacks()) else None - if worldpack is None: - from ..worldpacks.models import worldpack_from_world_record + row = session.get(AuthorWorkRow, payload["work_id"]) + if row is None: + row = AuthorWorkRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.world_version_id = payload["world_version_id"] + row.account_id = payload["account_id"] + row.title = payload["title"] + row.status = payload["status"] + row.current_revision = payload["current_revision"] + row.chapter_count = payload["chapter_count"] + row.target_chapter_count = payload["target_chapter_count"] + row.branch_id = payload["branch_id"] + row.root_work_id = payload["root_work_id"] + row.parent_work_id = payload["parent_work_id"] + row.branch_name = payload["branch_name"] + row.branch_kind = payload["branch_kind"] + row.branch_origin_label = payload["branch_origin_label"] + row.fork_after_chapter_index = payload["fork_after_chapter_index"] + row.is_active_line = payload["is_active_line"] + row.narrative_state_json = payload["narrative_state_json"] + row.diagnostics_summary_json = payload["diagnostics_summary_json"] + row.updated_at = now + session.commit() + return _author_work_payload(row) - worldpack = worldpack_from_world_record(world_record, initial_state=NarrativeState.from_dict({"state_id": "%s__bootstrap" % world_record.world.world_id, "world_id": world_record.world.world_id, "turn_index": 0, "story_phase": "setup", "chapter_index": 0, "min_end_turn": 8, "fate_pressure": 0.1, "karmic_weather": {}, "unresolved_debts": [], "world_facts": [], "timeline": [], "characters": {}, "relationship_graph": [], "open_promises": [], "tension": 0.0, "themes": {}, "player_intent": {}, "recent_scene_functions": [], "visited_event_ids": [], "route_fingerprint": [], "rating_ceiling": "PG13"})) - world_version = WorldVersion.from_worldpack( - worldpack=worldpack, - world_version_id="%s@%s" % (worldpack.world_id, worldpack.version), - status="published", - ) - self.save_world_version(world_version, publish=True) - return world_record + def get_author_work(self, work_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(AuthorWorkRow, work_id) + if row is None: + raise KeyError(f"unknown_author_work:{work_id}") + return _author_work_payload(row) - # Sessions / chapters - def create_session_record( + def list_author_works( self, *, - world_version_id: str, - initial_state: NarrativeState, - reader_id: Optional[str] = None, - player_profile: Optional[Dict[str, Any]] = None, - session_id: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - entitlements_snapshot: Optional[Dict[str, Any]] = None, - ) -> SessionRecord: - world_version = self.get_world_version(world_version_id) - record = SessionRecord( - session_id=session_id or "session_%s" % uuid4().hex[:12], - world_id=world_version.world_id, - player_profile=dict(player_profile or {}), - initial_state=initial_state, - current_state=initial_state, - created_at=utcnow_iso(), - metadata={"world_version_id": world_version_id, **dict(metadata or {})}, - ) + account_id: Optional[str] = None, + world_version_id: Optional[str] = None, + root_work_id: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - session.add( - SessionRow( - session_id=record.session_id, - reader_id=reader_id, - world_version_id=world_version_id, - status="active", - chapter_index=initial_state.chapter_index, - story_phase=initial_state.story_phase, - narrative_state_json=record.current_state.to_dict(), - entitlements_snapshot_json=dict(entitlements_snapshot or {}), - created_at=record.created_at, - updated_at=record.created_at, + stmt = select(AuthorWorkRow).order_by(desc(AuthorWorkRow.updated_at)) + if account_id is not None: + stmt = stmt.where(AuthorWorkRow.account_id == account_id) + if world_version_id is not None: + stmt = stmt.where(AuthorWorkRow.world_version_id == world_version_id) + if root_work_id is not None: + stmt = stmt.where(AuthorWorkRow.root_work_id == root_work_id) + if status is not None: + stmt = stmt.where(AuthorWorkRow.status == status) + rows = session.execute(stmt.limit(limit)).scalars().all() + return [_author_work_payload(row) for row in rows] + + def set_author_work_active_line(self, *, root_work_id: str, active_work_id: str) -> None: + with self.SessionLocal() as session: + rows = session.execute( + select(AuthorWorkRow).where(AuthorWorkRow.root_work_id == root_work_id) + ).scalars().all() + for row in rows: + row.is_active_line = 1 if row.work_id == active_work_id else 0 + row.updated_at = utcnow_iso() + session.commit() + + def delete_author_work_family(self, *, root_work_id: str) -> Dict[str, Any]: + normalized_root_work_id = str(root_work_id or "").strip() + if not normalized_root_work_id: + raise KeyError("unknown_author_work_family:") + with self.SessionLocal() as session: + work_rows = session.execute( + select(AuthorWorkRow).where( + or_( + AuthorWorkRow.root_work_id == normalized_root_work_id, + AuthorWorkRow.work_id == normalized_root_work_id, + ) ) - ) + ).scalars().all() + if not work_rows: + raise KeyError(f"unknown_author_work_family:{normalized_root_work_id}") + work_ids = [str(row.work_id) for row in work_rows] + chapter_rows = session.execute( + select(AuthorWorkChapterRow).where(AuthorWorkChapterRow.work_id.in_(work_ids)) + ).scalars().all() + revision_rows = session.execute( + select(AuthorWorkRevisionRow).where(AuthorWorkRevisionRow.work_id.in_(work_ids)) + ).scalars().all() + for row in chapter_rows: + session.delete(row) + for row in revision_rows: + session.delete(row) + deleted_titles = [] + for row in work_rows: + if row.title and row.title not in deleted_titles: + deleted_titles.append(row.title) + session.delete(row) session.commit() - return record + return { + "root_work_id": normalized_root_work_id, + "deleted_work_ids": work_ids, + "deleted_work_count": len(work_ids), + "deleted_chapter_count": len(chapter_rows), + "deleted_revision_count": len(revision_rows), + "deleted_titles": deleted_titles, + } - def create_session( - self, - world_id: str, - initial_state: NarrativeState, - *, - player_profile: Optional[Dict[str, Any]] = None, - session_id: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SessionRecord: - world_card = next((card for card in self.list_worlds() if card["world_id"] == world_id), None) - if world_card is None: - raise KeyError("unknown_world:%s" % world_id) - return self.create_session_record( - world_version_id=world_card["latest_version"], - initial_state=initial_state, - player_profile=player_profile, - session_id=session_id, - metadata=metadata, - ) + def save_author_work_chapter(self, chapter: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "chapter_record_id": str(chapter.get("chapter_record_id") or f"workchapter_{uuid4().hex[:12]}"), + "work_id": str(chapter["work_id"]), + "chapter_index": int(chapter["chapter_index"]), + "chapter_title": str(chapter.get("chapter_title") or f"第 {int(chapter['chapter_index'])} 章"), + "body": str(chapter.get("body") or ""), + "status": str(chapter.get("status") or "generated"), + "source_type": str(chapter.get("source_type") or "generated"), + "summary": chapter.get("summary"), + "diagnostic_summary_json": dict(chapter.get("diagnostic_summary_json") or {}), + "chapter_task_json": dict(chapter.get("chapter_task_json") or {}), + "choices_json": list(chapter.get("choices_json") or []), + "state_snapshot_json": dict(chapter.get("state_snapshot_json") or {}), + } + with self.SessionLocal() as session: + stmt = select(AuthorWorkChapterRow).where( + AuthorWorkChapterRow.work_id == payload["work_id"], + AuthorWorkChapterRow.chapter_index == payload["chapter_index"], + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + row = AuthorWorkChapterRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.chapter_title = payload["chapter_title"] + row.body = payload["body"] + row.status = payload["status"] + row.source_type = payload["source_type"] + row.summary = payload["summary"] + row.diagnostic_summary_json = payload["diagnostic_summary_json"] + row.chapter_task_json = payload["chapter_task_json"] + row.choices_json = payload["choices_json"] + row.state_snapshot_json = payload["state_snapshot_json"] + row.updated_at = now + session.commit() + return _author_work_chapter_payload(row) - def get_session(self, session_id: str) -> SessionRecord: + def get_author_work_chapter(self, *, work_id: str, chapter_index: int) -> Dict[str, Any]: with self.SessionLocal() as session: - row = session.get(SessionRow, session_id) + stmt = select(AuthorWorkChapterRow).where( + AuthorWorkChapterRow.work_id == work_id, + AuthorWorkChapterRow.chapter_index == chapter_index, + ) + row = session.execute(stmt).scalar_one_or_none() if row is None: - raise KeyError("unknown_session:%s" % session_id) - world_version = self.get_world_version(row.world_version_id) - current_state = NarrativeState.from_dict(dict(row.narrative_state_json)) - return SessionRecord( - session_id=row.session_id, - world_id=world_version.world_id, - player_profile={"reader_id": row.reader_id} if row.reader_id else {}, - initial_state=current_state, - current_state=current_state, - created_at=row.created_at, - metadata={ - "world_version_id": row.world_version_id, - "reader_id": row.reader_id, - "entitlements_snapshot": dict(row.entitlements_snapshot_json or {}), - }, + raise KeyError(f"unknown_author_work_chapter:{work_id}:{chapter_index}") + return _author_work_chapter_payload(row) + + def list_author_work_chapters(self, *, work_id: str) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = ( + select(AuthorWorkChapterRow) + .where(AuthorWorkChapterRow.work_id == work_id) + .order_by(AuthorWorkChapterRow.chapter_index.asc()) ) + rows = session.execute(stmt).scalars().all() + return [_author_work_chapter_payload(row) for row in rows] - def update_session_entitlements_snapshot(self, session_id: str, snapshot: Dict[str, Any]) -> Dict[str, Any]: + def save_author_work_revision(self, revision: Dict[str, Any]) -> Dict[str, Any]: + payload = { + "revision_id": str(revision.get("revision_id") or f"workrev_{uuid4().hex[:12]}"), + "work_id": str(revision["work_id"]), + "revision_type": str(revision.get("revision_type") or "update"), + "summary": revision.get("summary"), + "snapshot_json": dict(revision.get("snapshot_json") or {}), + } + now = utcnow_iso() with self.SessionLocal() as session: - row = session.get(SessionRow, session_id) + row = session.get(AuthorWorkRevisionRow, payload["revision_id"]) if row is None: - raise KeyError("unknown_session:%s" % session_id) - row.entitlements_snapshot_json = dict(snapshot or {}) - row.updated_at = utcnow_iso() + row = AuthorWorkRevisionRow(created_at=now, **payload) + session.add(row) + else: + row.work_id = payload["work_id"] + row.revision_type = payload["revision_type"] + row.summary = payload["summary"] + row.snapshot_json = payload["snapshot_json"] session.commit() - return dict(row.entitlements_snapshot_json or {}) - - def list_sessions(self, world_id: Optional[str] = None) -> List[Dict[str, Any]]: - with self.SessionLocal() as session: - stmt = select(SessionRow).order_by(desc(SessionRow.updated_at)) - rows = session.execute(stmt).scalars() - results = [] - for row in rows: - world_version = self.get_world_version(row.world_version_id) - if world_id is not None and world_version.world_id != world_id: - continue - latest_step = self.get_latest_step(row.session_id) - results.append( - { - "session_id": row.session_id, - "world_id": world_version.world_id, - "world_version_id": row.world_version_id, - "created_at": row.created_at, - "current_turn_index": row.chapter_index, - "last_event_title": latest_step.chosen_event.title if latest_step and latest_step.chosen_event else None, - "last_chapter_title": latest_step.reader_view.chapter_title if latest_step and latest_step.reader_view else None, - } - ) - return results + return _author_work_revision_payload(row) - def save_step(self, step_record: StepRecord, *, world_version_id: Optional[str] = None, entitlements_snapshot: Optional[Dict[str, Any]] = None, cost_estimate: Optional[float] = None) -> StepRecord: - created_at = step_record.created_at or utcnow_iso() - step_record.created_at = created_at + def list_author_work_revisions(self, *, work_id: str, limit: int = 50) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - session_row = session.get(SessionRow, step_record.session_id) - if session_row is None: - raise KeyError("unknown_session:%s" % step_record.session_id) - chapter_id = "chapter_%s_%s" % (step_record.session_id, step_record.step_index) - try: - session.add( - ChapterRow( - chapter_id=chapter_id, - session_id=step_record.session_id, - world_version_id=world_version_id or session_row.world_version_id, - chapter_index=step_record.step_index, - plan_json={ - "step_record": step_record.to_dict(), - "chapter_plan": step_record.chapter_plan.to_dict() if step_record.chapter_plan else None, - }, - rendered_body=step_record.reader_view.body if step_record.reader_view else (step_record.rendered_scene.premium_prose if step_record.rendered_scene else ""), - choices_json=step_record.reader_view.choices if step_record.reader_view else [], - cost_estimate=cost_estimate, - review_flags_json={"critic_trace": step_record.critic_trace}, - created_at=created_at, - ) - ) - session_row.chapter_index = step_record.state_after.chapter_index - session_row.story_phase = step_record.state_after.story_phase - session_row.narrative_state_json = step_record.state_after.to_dict() - session_row.entitlements_snapshot_json = dict(entitlements_snapshot or (session_row.entitlements_snapshot_json or {})) - session_row.updated_at = created_at - session.commit() - except IntegrityError: - session.rollback() - existing = session.get(ChapterRow, chapter_id) - if existing is None: - raise - payload = dict(existing.plan_json or {}) - if payload.get("step_record"): - return StepRecord.from_dict(payload["step_record"]) - return step_record - return step_record + stmt = ( + select(AuthorWorkRevisionRow) + .where(AuthorWorkRevisionRow.work_id == work_id) + .order_by(desc(AuthorWorkRevisionRow.created_at)) + .limit(limit) + ) + rows = session.execute(stmt).scalars().all() + return [_author_work_revision_payload(row) for row in rows] - def save_evaluation_report(self, chapter_id: str, report: EvaluationReport) -> Dict[str, Any]: + # Ops review hub + def save_ops_review_item(self, item: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + review_item_id = str(item.get("review_item_id") or f"ops_review_{uuid4().hex[:12]}") + payload = { + "review_item_id": review_item_id, + "source_type": str(item["source_type"]), + "source_id": str(item["source_id"]), + "queue": str(item.get("queue") or "triage"), + "status": str(item.get("status") or "new"), + "severity": str(item.get("severity") or "medium"), + "priority": int(item.get("priority", 100) or 100), + "owner_id": item.get("owner_id"), + "reviewer_id": item.get("reviewer_id"), + "account_id": item.get("account_id"), + "world_id": item.get("world_id"), + "world_version_id": item.get("world_version_id"), + "headline": str(item.get("headline") or item.get("source_id") or review_item_id), + "summary": item.get("summary"), + "recommended_action": item.get("recommended_action"), + "due_at": item.get("due_at"), + "sla_bucket": item.get("sla_bucket"), + "allowed_actions_json": list(item.get("allowed_actions") or []), + "linked_entities_json": list(item.get("linked_entities") or []), + "source_updated_at": item.get("source_updated_at"), + "last_synced_at": item.get("last_synced_at") or now, + } with self.SessionLocal() as session: - row = session.get(ChapterRow, chapter_id) + row = session.get(OpsReviewItemRow, payload["review_item_id"]) if row is None: - raise KeyError("unknown_chapter:%s" % chapter_id) - payload = dict(row.review_flags_json or {}) - payload["evaluation_report"] = report.to_dict() - row.review_flags_json = payload + row = OpsReviewItemRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.source_type = payload["source_type"] + row.source_id = payload["source_id"] + row.queue = payload["queue"] + row.status = payload["status"] + row.severity = payload["severity"] + row.priority = payload["priority"] + row.owner_id = payload["owner_id"] + row.reviewer_id = payload["reviewer_id"] + row.account_id = payload["account_id"] + row.world_id = payload["world_id"] + row.world_version_id = payload["world_version_id"] + row.headline = payload["headline"] + row.summary = payload["summary"] + row.recommended_action = payload["recommended_action"] + row.due_at = payload["due_at"] + row.sla_bucket = payload["sla_bucket"] + row.allowed_actions_json = payload["allowed_actions_json"] + row.linked_entities_json = payload["linked_entities_json"] + row.source_updated_at = payload["source_updated_at"] + row.last_synced_at = payload["last_synced_at"] + row.updated_at = now session.commit() - return report.to_dict() + return _ops_review_item_payload(row) - def get_evaluation_report(self, chapter_id: str) -> Optional[Dict[str, Any]]: + def upsert_ops_review_item_by_source(self, item: Dict[str, Any]) -> Dict[str, Any]: + source_type = str(item["source_type"]) + source_id = str(item["source_id"]) with self.SessionLocal() as session: - row = session.get(ChapterRow, chapter_id) + stmt = ( + select(OpsReviewItemRow) + .where(OpsReviewItemRow.source_type == source_type) + .where(OpsReviewItemRow.source_id == source_id) + ) + row = session.execute(stmt).scalar_one_or_none() + review_item_id = row.review_item_id if row is not None else item.get("review_item_id") + return self.save_ops_review_item({**item, "review_item_id": review_item_id}) + + def get_ops_review_item(self, review_item_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(OpsReviewItemRow, review_item_id) if row is None: - raise KeyError("unknown_chapter:%s" % chapter_id) - payload = dict(row.review_flags_json or {}) - return payload.get("evaluation_report") + raise KeyError(f"unknown_ops_review_item:{review_item_id}") + return _ops_review_item_payload(row) - def list_evaluation_reports( + def list_ops_review_items( self, *, + queue: Optional[str] = None, + status: Optional[str] = None, + owner_id: Optional[str] = None, + severity: Optional[str] = None, + source_type: Optional[str] = None, + account_id: Optional[str] = None, + world_id: Optional[str] = None, world_version_id: Optional[str] = None, - session_id: Optional[str] = None, + limit: int = 100, ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - stmt = select(ChapterRow).order_by(desc(ChapterRow.created_at)) + stmt = select(OpsReviewItemRow) + if queue is not None: + stmt = stmt.where(OpsReviewItemRow.queue == queue) + if status is not None: + stmt = stmt.where(OpsReviewItemRow.status == status) + if owner_id is not None: + stmt = stmt.where(OpsReviewItemRow.owner_id == owner_id) + if severity is not None: + stmt = stmt.where(OpsReviewItemRow.severity == severity) + if source_type is not None: + stmt = stmt.where(OpsReviewItemRow.source_type == source_type) + if account_id is not None: + stmt = stmt.where(OpsReviewItemRow.account_id == account_id) + if world_id is not None: + stmt = stmt.where(OpsReviewItemRow.world_id == world_id) if world_version_id is not None: - stmt = stmt.where(ChapterRow.world_version_id == world_version_id) - if session_id is not None: - stmt = stmt.where(ChapterRow.session_id == session_id) - rows = session.execute(stmt).scalars() - reports = [] - for row in rows: - payload = dict(row.review_flags_json or {}) - if payload.get("evaluation_report"): - reports.append(payload["evaluation_report"]) - return reports + stmt = stmt.where(OpsReviewItemRow.world_version_id == world_version_id) + stmt = stmt.order_by(OpsReviewItemRow.priority.asc(), desc(OpsReviewItemRow.updated_at)).limit(limit) + rows = session.execute(stmt).scalars().all() + return [_ops_review_item_payload(row) for row in rows] - def list_steps(self, session_id: str) -> List[StepRecord]: + def save_quality_policy(self, policy: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "policy_id": str(policy.get("policy_id") or f"quality_policy_{uuid4().hex[:12]}"), + "version": str(policy.get("version") or "v1"), + "scenario_id": str(policy.get("scenario_id") or ""), + "risk_tier": str(policy.get("risk_tier") or ""), + "mode": str(policy.get("mode") or "observe"), + "rule_ids_json": [str(item) for item in list(policy.get("rule_ids") or []) if str(item)], + "policy_payload_json": dict(policy.get("policy_payload") or policy.get("metadata") or {}), + } with self.SessionLocal() as session: - rows = session.execute( - select(ChapterRow).where(ChapterRow.session_id == session_id).order_by(ChapterRow.chapter_index.asc()) - ).scalars() - results = [] - for row in rows: - payload = dict(row.plan_json or {}) - if payload.get("step_record"): - results.append(StepRecord.from_dict(payload["step_record"])) - return results + row = session.get(QualityPolicyRow, payload["policy_id"]) + if row is None: + row = QualityPolicyRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.version = payload["version"] + row.scenario_id = payload["scenario_id"] + row.risk_tier = payload["risk_tier"] + row.mode = payload["mode"] + row.rule_ids_json = payload["rule_ids_json"] + row.policy_payload_json = payload["policy_payload_json"] + row.updated_at = now + session.commit() + return _quality_policy_payload(row) - def get_latest_step(self, session_id: str) -> Optional[StepRecord]: - steps = self.list_steps(session_id) - return steps[-1] if steps else None + def list_quality_policies( + self, + *, + scenario_id: Optional[str] = None, + risk_tier: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(QualityPolicyRow) + if scenario_id is not None: + stmt = stmt.where(QualityPolicyRow.scenario_id == scenario_id) + if risk_tier is not None: + stmt = stmt.where(QualityPolicyRow.risk_tier == risk_tier) + stmt = stmt.order_by(desc(QualityPolicyRow.updated_at)).limit(limit) + rows = session.execute(stmt).scalars().all() + return [_quality_policy_payload(row) for row in rows] - def get_replay(self, session_id: str) -> Dict[str, Any]: - session_record = self.get_session(session_id) - steps = self.list_steps(session_id) - evaluation_reports = self.list_evaluation_reports(session_id=session_id) - return { - "session": session_record.to_dict(), - "full_timeline": [step.chosen_event.title for step in steps if step.chosen_event], - "event_trace": [step.chosen_event.to_dict() for step in steps if step.chosen_event], - "reader_views": [step.reader_view.to_dict() for step in steps if step.reader_view], - "critic_trace": [step.critic_trace for step in steps], - "state_snapshots": [session_record.initial_state.to_dict()] + [step.state_after.to_dict() for step in steps], - "promise_ledger_snapshots": [[promise.to_dict() for promise in step.promise_ledger_snapshot] for step in steps], - "rendered_scenes": [step.rendered_scene.to_dict() for step in steps if step.rendered_scene], - "evaluation_reports": evaluation_reports, + def save_ops_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "ops_config_id": str(config.get("ops_config_id") or f"ops_config_{uuid4().hex[:12]}"), + "config_type": str(config.get("config_type") or ""), + "scope_key": str(config.get("scope_key") or "").strip() or None, + "status": str(config.get("status") or "active"), + "config_payload_json": dict(config.get("config_payload") or config.get("metadata") or {}), } - - def delete_session(self, session_id: str) -> Dict[str, Any]: with self.SessionLocal() as session: - row = session.get(SessionRow, session_id) + row = session.get(OpsConfigRow, payload["ops_config_id"]) if row is None: - raise KeyError("unknown_session:%s" % session_id) - chapter_rows = session.execute(select(ChapterRow).where(ChapterRow.session_id == session_id)).scalars() - deleted_steps = 0 - for chapter in chapter_rows: - session.delete(chapter) - deleted_steps += 1 - session.delete(row) + row = OpsConfigRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.config_type = payload["config_type"] + row.scope_key = payload["scope_key"] + row.status = payload["status"] + row.config_payload_json = payload["config_payload_json"] + row.updated_at = now session.commit() - return {"session_id": session_id, "deleted_steps": deleted_steps} + return _ops_config_payload(row) - # Review / publish / rollback - def save_review_record(self, review: Dict[str, Any]) -> Dict[str, Any]: + def list_ops_configs( + self, + *, + config_type: Optional[str] = None, + scope_key: Optional[str] = None, + status: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(OpsConfigRow) + if config_type is not None: + stmt = stmt.where(OpsConfigRow.config_type == config_type) + if scope_key is not None: + stmt = stmt.where(OpsConfigRow.scope_key == scope_key) + if status is not None: + stmt = stmt.where(OpsConfigRow.status == status) + stmt = stmt.order_by(desc(OpsConfigRow.updated_at)).limit(limit) + rows = session.execute(stmt).scalars().all() + return [_ops_config_payload(row) for row in rows] + + def save_quality_event(self, event: Dict[str, Any]) -> Dict[str, Any]: payload = { - "review_id": review.get("review_id") or "review_%s" % uuid4().hex[:12], - "asset_type": review["asset_type"], - "asset_id": review["asset_id"], - "status": review["status"], - "reviewer_id": review.get("reviewer_id"), - "risk_rating": review.get("risk_rating"), - "notes": review.get("notes"), + "event_id": str(event.get("event_id") or f"quality_event_{uuid4().hex[:12]}"), + "trace_id": str(event.get("trace_id") or ""), + "event_type": str(event.get("event_type") or ""), + "source_surface": str(event.get("source_surface") or ""), + "status": event.get("status"), + "world_version_id": event.get("world_version_id"), + "session_id": event.get("session_id"), + "source_ref_json": dict(event.get("source_ref") or {}), + "payload_json": dict(event.get("payload") or {}), + "created_at": str(event.get("created_at") or utcnow_iso()), } - now = utcnow_iso() with self.SessionLocal() as session: - row = session.get(ReviewRecordRow, payload["review_id"]) + row = session.get(QualityEventRow, payload["event_id"]) if row is None: - row = ReviewRecordRow(created_at=now, updated_at=now, **payload) + row = QualityEventRow(**payload) session.add(row) else: - row.asset_type = payload["asset_type"] - row.asset_id = payload["asset_id"] + row.trace_id = payload["trace_id"] + row.event_type = payload["event_type"] + row.source_surface = payload["source_surface"] row.status = payload["status"] - row.reviewer_id = payload["reviewer_id"] - row.risk_rating = payload["risk_rating"] - row.notes = payload["notes"] - row.updated_at = now + row.world_version_id = payload["world_version_id"] + row.session_id = payload["session_id"] + row.source_ref_json = payload["source_ref_json"] + row.payload_json = payload["payload_json"] + row.created_at = payload["created_at"] session.commit() - payload["created_at"] = now - payload["updated_at"] = now - return payload + return _quality_event_payload(row) - def save_author_comment_thread(self, thread: Dict[str, Any]) -> Dict[str, Any]: + def list_quality_events( + self, + *, + trace_id: Optional[str] = None, + source_surface: Optional[str] = None, + status: Optional[str] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(QualityEventRow) + if trace_id is not None: + stmt = stmt.where(QualityEventRow.trace_id == trace_id) + if source_surface is not None: + stmt = stmt.where(QualityEventRow.source_surface == source_surface) + if status is not None: + stmt = stmt.where(QualityEventRow.status == status) + if world_version_id is not None: + stmt = stmt.where(QualityEventRow.world_version_id == world_version_id) + if session_id is not None: + stmt = stmt.where(QualityEventRow.session_id == session_id) + stmt = stmt.order_by(desc(QualityEventRow.created_at)).limit(limit) + rows = session.execute(stmt).scalars().all() + return [_quality_event_payload(row) for row in rows] + + def save_content_quality_score(self, score: Dict[str, Any]) -> Dict[str, Any]: payload = { - "thread_id": thread.get("thread_id") or "athread_%s" % uuid4().hex[:12], - "world_version_id": thread["world_version_id"], - "revision_id": thread.get("revision_id"), - "anchor_type": thread["anchor_type"], - "anchor_key": thread["anchor_key"], - "status": thread.get("status", "open"), - "severity": thread.get("severity", "normal"), - "assignee_id": thread.get("assignee_id"), - "created_by": thread["created_by"], + "score_id": str(score.get("score_id") or f"quality_score_{uuid4().hex[:12]}"), + "trace_id": score.get("trace_id"), + "source_surface": str(score.get("source_surface") or ""), + "status": score.get("status"), + "world_version_id": score.get("world_version_id"), + "session_id": score.get("session_id"), + "chapter_id": score.get("chapter_id"), + "rubric_version": str(score.get("rubric_version") or ""), + "overall_score": float(score.get("overall_score", 0.0) or 0.0), + "veto": bool(score.get("veto", False)), + "dimension_scores_json": dict(score.get("dimension_scores") or {}), + "reason_codes_json": [str(item) for item in list(score.get("reason_codes") or []) if str(item)], + "evidence_refs_json": [dict(item or {}) for item in list(score.get("evidence_refs") or [])], + "score_payload_json": dict(score.get("score_payload") or score.get("metadata") or {}), + "created_at": str(score.get("created_at") or utcnow_iso()), } - now = utcnow_iso() with self.SessionLocal() as session: - row = session.get(AuthorCommentThreadRow, payload["thread_id"]) + row = session.get(ContentQualityScoreRow, payload["score_id"]) if row is None: - row = AuthorCommentThreadRow(created_at=now, updated_at=now, **payload) + row = ContentQualityScoreRow(**payload) session.add(row) else: + row.trace_id = payload["trace_id"] + row.source_surface = payload["source_surface"] + row.status = payload["status"] row.world_version_id = payload["world_version_id"] - row.revision_id = payload["revision_id"] - row.anchor_type = payload["anchor_type"] - row.anchor_key = payload["anchor_key"] + row.session_id = payload["session_id"] + row.chapter_id = payload["chapter_id"] + row.rubric_version = payload["rubric_version"] + row.overall_score = payload["overall_score"] + row.veto = payload["veto"] + row.dimension_scores_json = payload["dimension_scores_json"] + row.reason_codes_json = payload["reason_codes_json"] + row.evidence_refs_json = payload["evidence_refs_json"] + row.score_payload_json = payload["score_payload_json"] + row.created_at = payload["created_at"] + session.commit() + return _content_quality_score_payload(row) + + def get_content_quality_score(self, score_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(ContentQualityScoreRow, score_id) + if row is None: + raise KeyError(f"unknown_content_quality_score:{score_id}") + return _content_quality_score_payload(row) + + def list_content_quality_scores( + self, + *, + trace_id: Optional[str] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + chapter_id: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ContentQualityScoreRow) + if trace_id is not None: + stmt = stmt.where(ContentQualityScoreRow.trace_id == trace_id) + if world_version_id is not None: + stmt = stmt.where(ContentQualityScoreRow.world_version_id == world_version_id) + if session_id is not None: + stmt = stmt.where(ContentQualityScoreRow.session_id == session_id) + if chapter_id is not None: + stmt = stmt.where(ContentQualityScoreRow.chapter_id == chapter_id) + stmt = stmt.order_by(desc(ContentQualityScoreRow.created_at)).limit(limit) + rows = session.execute(stmt).scalars().all() + return [_content_quality_score_payload(row) for row in rows] + + def save_review_case(self, case: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "case_id": str(case.get("case_id") or f"review_case_{uuid4().hex[:12]}"), + "trace_id": case.get("trace_id"), + "case_type": str(case.get("case_type") or ""), + "status": str(case.get("status") or "open"), + "owner_id": case.get("owner_id"), + "source_surface": case.get("source_surface"), + "world_version_id": case.get("world_version_id"), + "session_id": case.get("session_id"), + "score_id": case.get("score_id"), + "source_ref_json": dict(case.get("source_ref") or {}), + "reason_codes_json": [str(item) for item in list(case.get("reason_codes") or []) if str(item)], + "evidence_refs_json": [dict(item or {}) for item in list(case.get("evidence_refs") or [])], + "case_payload_json": dict(case.get("case_payload") or case.get("metadata") or {}), + } + with self.SessionLocal() as session: + row = session.get(ReviewCaseRow, payload["case_id"]) + if row is None: + row = ReviewCaseRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.trace_id = payload["trace_id"] + row.case_type = payload["case_type"] row.status = payload["status"] - row.severity = payload["severity"] - row.assignee_id = payload["assignee_id"] - row.created_by = payload["created_by"] + row.owner_id = payload["owner_id"] + row.source_surface = payload["source_surface"] + row.world_version_id = payload["world_version_id"] + row.session_id = payload["session_id"] + row.score_id = payload["score_id"] + row.source_ref_json = payload["source_ref_json"] + row.reason_codes_json = payload["reason_codes_json"] + row.evidence_refs_json = payload["evidence_refs_json"] + row.case_payload_json = payload["case_payload_json"] row.updated_at = now session.commit() - payload["created_at"] = now - payload["updated_at"] = now - return payload + return _review_case_payload(row) - def list_author_comment_threads( + def get_review_case(self, case_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(ReviewCaseRow, case_id) + if row is None: + raise KeyError(f"unknown_review_case:{case_id}") + return _review_case_payload(row) + + def list_review_cases( self, *, - world_version_id: Optional[str] = None, - revision_id: Optional[str] = None, status: Optional[str] = None, - anchor_type: Optional[str] = None, - assignee_id: Optional[str] = None, + case_type: Optional[str] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + trace_id: Optional[str] = None, + limit: int = 100, ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - stmt = select(AuthorCommentThreadRow).order_by(desc(AuthorCommentThreadRow.updated_at)) - if world_version_id is not None: - stmt = stmt.where(AuthorCommentThreadRow.world_version_id == world_version_id) - if revision_id is not None: - stmt = stmt.where(AuthorCommentThreadRow.revision_id == revision_id) + stmt = select(ReviewCaseRow) if status is not None: - stmt = stmt.where(AuthorCommentThreadRow.status == status) - if anchor_type is not None: - stmt = stmt.where(AuthorCommentThreadRow.anchor_type == anchor_type) - if assignee_id is not None: - stmt = stmt.where(AuthorCommentThreadRow.assignee_id == assignee_id) - rows = session.execute(stmt).scalars() - return [ - { - "thread_id": row.thread_id, - "world_version_id": row.world_version_id, - "revision_id": row.revision_id, - "anchor_type": row.anchor_type, - "anchor_key": row.anchor_key, - "status": row.status, - "severity": row.severity, - "assignee_id": row.assignee_id, - "created_by": row.created_by, - "created_at": row.created_at, - "updated_at": row.updated_at, - } - for row in rows - ] + stmt = stmt.where(ReviewCaseRow.status == status) + if case_type is not None: + stmt = stmt.where(ReviewCaseRow.case_type == case_type) + if world_version_id is not None: + stmt = stmt.where(ReviewCaseRow.world_version_id == world_version_id) + if session_id is not None: + stmt = stmt.where(ReviewCaseRow.session_id == session_id) + if trace_id is not None: + stmt = stmt.where(ReviewCaseRow.trace_id == trace_id) + stmt = stmt.order_by(desc(ReviewCaseRow.updated_at)).limit(limit) + rows = session.execute(stmt).scalars().all() + return [_review_case_payload(row) for row in rows] - def get_author_comment_thread(self, thread_id: str) -> Dict[str, Any]: + def update_review_case_status( + self, + case_id: str, + *, + status: str, + owner_id: Optional[str] = None, + reason_codes: Optional[List[str]] = None, + evidence_refs: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: with self.SessionLocal() as session: - row = session.get(AuthorCommentThreadRow, thread_id) + row = session.get(ReviewCaseRow, case_id) if row is None: - raise KeyError("unknown_author_comment_thread:%s" % thread_id) - return { - "thread_id": row.thread_id, - "world_version_id": row.world_version_id, - "revision_id": row.revision_id, - "anchor_type": row.anchor_type, - "anchor_key": row.anchor_key, - "status": row.status, - "severity": row.severity, - "assignee_id": row.assignee_id, - "created_by": row.created_by, - "created_at": row.created_at, - "updated_at": row.updated_at, - } + raise KeyError(f"unknown_review_case:{case_id}") + row.status = str(status) + if owner_id is not None: + row.owner_id = owner_id + if reason_codes is not None: + row.reason_codes_json = [str(item) for item in reason_codes if str(item)] + if evidence_refs is not None: + row.evidence_refs_json = [dict(item or {}) for item in evidence_refs] + row.updated_at = utcnow_iso() + session.commit() + return _review_case_payload(row) - def save_author_comment_message(self, message: Dict[str, Any]) -> Dict[str, Any]: + def save_quality_feedback_item(self, item: Dict[str, Any]) -> Dict[str, Any]: payload = { - "message_id": message.get("message_id") or "acomment_%s" % uuid4().hex[:12], - "thread_id": message["thread_id"], - "actor_id": message["actor_id"], - "actor_role": message["actor_role"], - "body": message["body"], + "feedback_item_id": str(item.get("feedback_item_id") or f"quality_feedback_{uuid4().hex[:12]}"), + "trace_id": item.get("trace_id"), + "source_event_id": item.get("source_event_id"), + "feedback_type": str(item.get("feedback_type") or ""), + "signal": str(item.get("signal") or ""), + "source_surface": str(item.get("source_surface") or ""), + "account_id": item.get("account_id"), + "world_version_id": item.get("world_version_id"), + "session_id": item.get("session_id"), + "chapter_id": item.get("chapter_id"), + "source_ref_json": dict(item.get("source_ref") or {}), + "payload_json": dict(item.get("payload") or {}), + "created_at": str(item.get("created_at") or utcnow_iso()), } - now = utcnow_iso() with self.SessionLocal() as session: - row = session.get(AuthorCommentMessageRow, payload["message_id"]) + row = session.get(QualityFeedbackItemRow, payload["feedback_item_id"]) if row is None: - row = AuthorCommentMessageRow(created_at=now, **payload) + row = QualityFeedbackItemRow(**payload) session.add(row) else: - row.thread_id = payload["thread_id"] - row.actor_id = payload["actor_id"] - row.actor_role = payload["actor_role"] - row.body = payload["body"] - thread_row = session.get(AuthorCommentThreadRow, payload["thread_id"]) - if thread_row is not None: - thread_row.updated_at = now + row.trace_id = payload["trace_id"] + row.source_event_id = payload["source_event_id"] + row.feedback_type = payload["feedback_type"] + row.signal = payload["signal"] + row.source_surface = payload["source_surface"] + row.account_id = payload["account_id"] + row.world_version_id = payload["world_version_id"] + row.session_id = payload["session_id"] + row.chapter_id = payload["chapter_id"] + row.source_ref_json = payload["source_ref_json"] + row.payload_json = payload["payload_json"] + row.created_at = payload["created_at"] session.commit() - payload["created_at"] = now - return payload + return _quality_feedback_item_payload(row) - def list_author_comment_messages(self, *, thread_id: str) -> List[Dict[str, Any]]: + def list_quality_feedback_items( + self, + *, + trace_id: Optional[str] = None, + account_id: Optional[str] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + chapter_id: Optional[str] = None, + feedback_type: Optional[str] = None, + signal: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - stmt = ( - select(AuthorCommentMessageRow) - .where(AuthorCommentMessageRow.thread_id == thread_id) - .order_by(AuthorCommentMessageRow.created_at.asc()) - ) - rows = session.execute(stmt).scalars() - return [ - { - "message_id": row.message_id, - "thread_id": row.thread_id, - "actor_id": row.actor_id, - "actor_role": row.actor_role, - "body": row.body, - "created_at": row.created_at, - } - for row in rows - ] + stmt = select(QualityFeedbackItemRow) + if trace_id is not None: + stmt = stmt.where(QualityFeedbackItemRow.trace_id == trace_id) + if account_id is not None: + stmt = stmt.where(QualityFeedbackItemRow.account_id == account_id) + if world_version_id is not None: + stmt = stmt.where(QualityFeedbackItemRow.world_version_id == world_version_id) + if session_id is not None: + stmt = stmt.where(QualityFeedbackItemRow.session_id == session_id) + if chapter_id is not None: + stmt = stmt.where(QualityFeedbackItemRow.chapter_id == chapter_id) + if feedback_type is not None: + stmt = stmt.where(QualityFeedbackItemRow.feedback_type == feedback_type) + if signal is not None: + stmt = stmt.where(QualityFeedbackItemRow.signal == signal) + stmt = stmt.order_by(desc(QualityFeedbackItemRow.created_at)).limit(limit) + rows = session.execute(stmt).scalars().all() + return [_quality_feedback_item_payload(row) for row in rows] - def save_author_approval_record(self, approval: Dict[str, Any]) -> Dict[str, Any]: + def save_grounding_check(self, item: Dict[str, Any]) -> Dict[str, Any]: payload = { - "approval_id": approval.get("approval_id") or "approval_%s" % uuid4().hex[:12], - "world_version_id": approval["world_version_id"], - "revision_id": approval.get("revision_id"), - "status": approval["status"], - "reviewer_id": approval["reviewer_id"], - "reason": approval["reason"], + "grounding_check_id": str(item.get("grounding_check_id") or f"grounding_check_{uuid4().hex[:12]}"), + "trace_id": item.get("trace_id"), + "status": str(item.get("status") or ""), + "confidence": float(item.get("confidence", 0.0) or 0.0), + "source_surface": str(item.get("source_surface") or ""), + "world_version_id": item.get("world_version_id"), + "session_id": item.get("session_id"), + "chapter_id": item.get("chapter_id"), + "evidence_refs_json": [dict(entry or {}) for entry in list(item.get("evidence_refs") or [])], + "unsupported_claims_json": [str(entry) for entry in list(item.get("unsupported_claims") or []) if str(entry)], + "reason_codes_json": [str(entry) for entry in list(item.get("reason_codes") or []) if str(entry)], + "summary": str(item.get("summary") or ""), + "created_at": str(item.get("created_at") or utcnow_iso()), } - now = utcnow_iso() with self.SessionLocal() as session: - row = session.get(AuthorApprovalRecordRow, payload["approval_id"]) + row = session.get(GroundingCheckRow, payload["grounding_check_id"]) if row is None: - row = AuthorApprovalRecordRow(created_at=now, updated_at=now, **payload) + row = GroundingCheckRow(**payload) session.add(row) else: - row.world_version_id = payload["world_version_id"] - row.revision_id = payload["revision_id"] + row.trace_id = payload["trace_id"] row.status = payload["status"] - row.reviewer_id = payload["reviewer_id"] - row.reason = payload["reason"] - row.updated_at = now + row.confidence = payload["confidence"] + row.source_surface = payload["source_surface"] + row.world_version_id = payload["world_version_id"] + row.session_id = payload["session_id"] + row.chapter_id = payload["chapter_id"] + row.evidence_refs_json = payload["evidence_refs_json"] + row.unsupported_claims_json = payload["unsupported_claims_json"] + row.reason_codes_json = payload["reason_codes_json"] + row.summary = payload["summary"] + row.created_at = payload["created_at"] session.commit() - payload["created_at"] = now - payload["updated_at"] = now - return payload + return _grounding_check_payload(row) - def list_author_approval_records( + def list_grounding_checks( self, *, - world_version_id: Optional[str] = None, - revision_id: Optional[str] = None, + trace_id: Optional[str] = None, status: Optional[str] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + chapter_id: Optional[str] = None, + limit: int = 100, ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - stmt = select(AuthorApprovalRecordRow).order_by(desc(AuthorApprovalRecordRow.updated_at)) - if world_version_id is not None: - stmt = stmt.where(AuthorApprovalRecordRow.world_version_id == world_version_id) - if revision_id is not None: - stmt = stmt.where(AuthorApprovalRecordRow.revision_id == revision_id) + stmt = select(GroundingCheckRow) + if trace_id is not None: + stmt = stmt.where(GroundingCheckRow.trace_id == trace_id) if status is not None: - stmt = stmt.where(AuthorApprovalRecordRow.status == status) - rows = session.execute(stmt).scalars() - return [ - { - "approval_id": row.approval_id, - "world_version_id": row.world_version_id, - "revision_id": row.revision_id, - "status": row.status, - "reviewer_id": row.reviewer_id, - "reason": row.reason, - "created_at": row.created_at, - "updated_at": row.updated_at, - } - for row in rows - ] + stmt = stmt.where(GroundingCheckRow.status == status) + if world_version_id is not None: + stmt = stmt.where(GroundingCheckRow.world_version_id == world_version_id) + if session_id is not None: + stmt = stmt.where(GroundingCheckRow.session_id == session_id) + if chapter_id is not None: + stmt = stmt.where(GroundingCheckRow.chapter_id == chapter_id) + stmt = stmt.order_by(desc(GroundingCheckRow.created_at)).limit(limit) + rows = session.execute(stmt).scalars().all() + return [_grounding_check_payload(row) for row in rows] - def save_author_notification(self, notification: Dict[str, Any]) -> Dict[str, Any]: - now = utcnow_iso() - payload = { - "notification_id": notification.get("notification_id") or "anotify_%s" % uuid4().hex[:12], - "world_version_id": notification["world_version_id"], - "thread_id": notification.get("thread_id"), + def get_world(self, world_id: str) -> WorldRecord: + with self.SessionLocal() as session: + row = session.get(WorldRow, world_id) + if row is None or not row.latest_version: + raise KeyError("unknown_world:%s" % world_id) + runtime = self.get_runtime_bundle(row.latest_version) + return runtime.world_record + + def get_runtime_bundle(self, world_version_id: str) -> RuntimeBundle: + version = self.get_world_version(world_version_id) + try: + return self.registry.get_runtime_bundle(world_version_id) + except KeyError: + return runtime_bundle_from_worldpack_data( + { + "world_version_id": world_version_id, + "world_id": version.world_id, + "status": version.status, + "worldpack": version.worldpack_json, + } + ) + + def create_world(self, world_record: WorldRecord) -> WorldRecord: + worldpack = WorldPack.from_dict(self.registry.get_published_world(world_record.world.world_id)["worldpack"]) if any(card["world_id"] == world_record.world.world_id for card in self.registry.list_worldpacks()) else None + if worldpack is None: + from ..worldpacks.models import worldpack_from_world_record + + worldpack = worldpack_from_world_record(world_record, initial_state=NarrativeState.from_dict({"state_id": "%s__bootstrap" % world_record.world.world_id, "world_id": world_record.world.world_id, "turn_index": 0, "story_phase": "setup", "chapter_index": 0, "min_end_turn": 8, "fate_pressure": 0.1, "karmic_weather": {}, "unresolved_debts": [], "world_facts": [], "timeline": [], "characters": {}, "relationship_graph": [], "open_promises": [], "tension": 0.0, "themes": {}, "player_intent": {}, "recent_scene_functions": [], "visited_event_ids": [], "route_fingerprint": [], "rating_ceiling": "PG13"})) + world_version = WorldVersion.from_worldpack( + worldpack=worldpack, + world_version_id="%s@%s" % (worldpack.world_id, worldpack.version), + status="published", + ) + self.save_world_version(world_version, publish=True) + return world_record + + # Sessions / chapters + def create_session_record( + self, + *, + world_version_id: str, + initial_state: NarrativeState, + reader_id: Optional[str] = None, + player_profile: Optional[Dict[str, Any]] = None, + session_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + entitlements_snapshot: Optional[Dict[str, Any]] = None, + ) -> SessionRecord: + world_version = self.get_world_version(world_version_id) + record = SessionRecord( + session_id=session_id or "session_%s" % uuid4().hex[:12], + world_id=world_version.world_id, + player_profile=dict(player_profile or {}), + initial_state=initial_state, + current_state=initial_state, + created_at=utcnow_iso(), + metadata={"world_version_id": world_version_id, **dict(metadata or {})}, + ) + with self.SessionLocal() as session: + session.add( + SessionRow( + session_id=record.session_id, + reader_id=reader_id, + world_version_id=world_version_id, + status="active", + chapter_index=initial_state.chapter_index, + story_phase=initial_state.story_phase, + narrative_state_json=record.current_state.to_dict(), + entitlements_snapshot_json=dict(entitlements_snapshot or {}), + created_at=record.created_at, + updated_at=record.created_at, + ) + ) + session.commit() + return record + + def create_session( + self, + world_id: str, + initial_state: NarrativeState, + *, + player_profile: Optional[Dict[str, Any]] = None, + session_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SessionRecord: + world_card = next((card for card in self.list_worlds() if card["world_id"] == world_id), None) + if world_card is None: + raise KeyError("unknown_world:%s" % world_id) + return self.create_session_record( + world_version_id=world_card["latest_version"], + initial_state=initial_state, + reader_id=str((player_profile or {}).get("reader_id") or "").strip() or None, + player_profile=player_profile, + session_id=session_id, + metadata=metadata, + ) + + def get_session(self, session_id: str) -> SessionRecord: + with self.SessionLocal() as session: + row = session.get(SessionRow, session_id) + if row is None: + raise KeyError("unknown_session:%s" % session_id) + world_version = self.get_world_version(row.world_version_id) + current_state = NarrativeState.from_dict(dict(row.narrative_state_json)) + return SessionRecord( + session_id=row.session_id, + world_id=world_version.world_id, + player_profile={"reader_id": row.reader_id} if row.reader_id else {}, + initial_state=current_state, + current_state=current_state, + created_at=row.created_at, + metadata={ + "world_version_id": row.world_version_id, + "reader_id": row.reader_id, + "entitlements_snapshot": dict(row.entitlements_snapshot_json or {}), + }, + ) + + def update_session_entitlements_snapshot(self, session_id: str, snapshot: Dict[str, Any]) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(SessionRow, session_id) + if row is None: + raise KeyError("unknown_session:%s" % session_id) + row.entitlements_snapshot_json = dict(snapshot or {}) + row.updated_at = utcnow_iso() + session.commit() + return dict(row.entitlements_snapshot_json or {}) + + def claim_guest_session(self, session_id: str, *, reader_id: str) -> Dict[str, Any]: + normalized_reader_id = str(reader_id or "").strip() + if not normalized_reader_id: + raise ValueError("reader_id_required") + now = utcnow_iso() + with self.SessionLocal() as session: + row = session.get(SessionRow, session_id) + if row is None: + raise KeyError("unknown_session:%s" % session_id) + current_reader_id = str(row.reader_id or "").strip() + if current_reader_id == normalized_reader_id: + return { + "session_id": row.session_id, + "reader_id": current_reader_id, + "world_version_id": row.world_version_id, + "status": "already_owned", + } + if current_reader_id: + return { + "session_id": row.session_id, + "reader_id": current_reader_id, + "world_version_id": row.world_version_id, + "status": "conflict", + } + + result = session.execute( + update(SessionRow) + .where( + SessionRow.session_id == session_id, + or_(SessionRow.reader_id.is_(None), SessionRow.reader_id == ""), + ) + .values(reader_id=normalized_reader_id, updated_at=now) + ) + if int(result.rowcount or 0) == 1: + session.commit() + return { + "session_id": session_id, + "reader_id": normalized_reader_id, + "world_version_id": row.world_version_id, + "status": "claimed", + } + + session.rollback() + refreshed = session.get(SessionRow, session_id) + if refreshed is None: + raise KeyError("unknown_session:%s" % session_id) + refreshed_reader_id = str(refreshed.reader_id or "").strip() + if refreshed_reader_id == normalized_reader_id: + return { + "session_id": refreshed.session_id, + "reader_id": refreshed_reader_id, + "world_version_id": refreshed.world_version_id, + "status": "already_owned", + } + return { + "session_id": refreshed.session_id, + "reader_id": refreshed_reader_id, + "world_version_id": refreshed.world_version_id, + "status": "conflict", + } + + def list_sessions(self, world_id: Optional[str] = None, reader_id: Optional[str] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(SessionRow).order_by(desc(SessionRow.updated_at)) + if reader_id is not None: + stmt = stmt.where(SessionRow.reader_id == str(reader_id or "").strip()) + rows = session.execute(stmt).scalars() + results = [] + for row in rows: + world_version = self.get_world_version(row.world_version_id) + if world_id is not None and world_version.world_id != world_id: + continue + latest_step = self.get_latest_step(row.session_id) + results.append( + { + "session_id": row.session_id, + "world_id": world_version.world_id, + "world_version_id": row.world_version_id, + "created_at": row.created_at, + "current_turn_index": row.chapter_index, + "last_event_title": latest_step.chosen_event.title if latest_step and latest_step.chosen_event else None, + "last_chapter_title": latest_step.reader_view.chapter_title if latest_step and latest_step.reader_view else None, + } + ) + return results + + def save_step(self, step_record: StepRecord, *, world_version_id: Optional[str] = None, entitlements_snapshot: Optional[Dict[str, Any]] = None, cost_estimate: Optional[float] = None) -> StepRecord: + created_at = step_record.created_at or utcnow_iso() + step_record.created_at = created_at + with self.SessionLocal() as session: + session_row = session.get(SessionRow, step_record.session_id) + if session_row is None: + raise KeyError("unknown_session:%s" % step_record.session_id) + chapter_id = "chapter_%s_%s" % (step_record.session_id, step_record.step_index) + try: + session.add( + ChapterRow( + chapter_id=chapter_id, + session_id=step_record.session_id, + world_version_id=world_version_id or session_row.world_version_id, + chapter_index=step_record.step_index, + plan_json=_chapter_plan_json_for_step(step_record), + rendered_body=step_record.reader_view.body if step_record.reader_view else (step_record.rendered_scene.premium_prose if step_record.rendered_scene else ""), + choices_json=step_record.reader_view.choices if step_record.reader_view else [], + cost_estimate=cost_estimate, + review_flags_json={"critic_trace": step_record.critic_trace}, + created_at=created_at, + ) + ) + session_row.chapter_index = step_record.state_after.chapter_index + session_row.story_phase = step_record.state_after.story_phase + session_row.narrative_state_json = step_record.state_after.to_dict() + session_row.entitlements_snapshot_json = dict(entitlements_snapshot or (session_row.entitlements_snapshot_json or {})) + session_row.updated_at = created_at + session.commit() + except IntegrityError: + session.rollback() + existing = session.get(ChapterRow, chapter_id) + if existing is None: + raise + payload = dict(existing.plan_json or {}) + if payload.get("step_record"): + return StepRecord.from_dict(payload["step_record"]) + replay_payload = _replay_payload_from_plan(payload) + replay_step = _step_record_from_replay_payload(replay_payload) + if replay_step is not None: + return replay_step + return step_record + return step_record + + def save_evaluation_report(self, chapter_id: str, report: EvaluationReport) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(ChapterRow, chapter_id) + if row is None: + raise KeyError("unknown_chapter:%s" % chapter_id) + payload = dict(row.review_flags_json or {}) + payload["evaluation_report"] = report.to_dict() + row.review_flags_json = payload + session.commit() + return report.to_dict() + + def save_route_choice( + self, + *, + session_id: str, + chapter_id: str, + choice_id: str, + payload_json: Optional[Dict[str, Any]] = None, + selected_at: Optional[str] = None, + ) -> Dict[str, Any]: + payload = { + "session_id": str(session_id), + "chapter_id": str(chapter_id), + "choice_id": str(choice_id or "director_intent"), + "selected_at": selected_at or utcnow_iso(), + "payload_json": dict(payload_json or {}), + } + with self.SessionLocal() as session: + row = RouteChoiceRow(**payload) + session.add(row) + session.commit() + return _route_choice_payload(row) + + def list_route_choices( + self, + *, + session_id: Optional[str] = None, + chapter_id: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(RouteChoiceRow).order_by(RouteChoiceRow.selected_at.asc(), RouteChoiceRow.choice_event_id.asc()) + if session_id is not None: + stmt = stmt.where(RouteChoiceRow.session_id == session_id) + if chapter_id is not None: + stmt = stmt.where(RouteChoiceRow.chapter_id == chapter_id) + rows = session.execute(stmt.limit(max(1, min(500, int(limit or 100))))).scalars().all() + return [_route_choice_payload(row) for row in rows] + + def get_evaluation_report(self, chapter_id: str) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(ChapterRow, chapter_id) + if row is None: + raise KeyError("unknown_chapter:%s" % chapter_id) + payload = dict(row.review_flags_json or {}) + return payload.get("evaluation_report") + + def list_evaluation_reports( + self, + *, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ChapterRow).order_by(desc(ChapterRow.created_at)) + if world_version_id is not None: + stmt = stmt.where(ChapterRow.world_version_id == world_version_id) + if session_id is not None: + stmt = stmt.where(ChapterRow.session_id == session_id) + rows = session.execute(stmt).scalars() + reports = [] + for row in rows: + payload = dict(row.review_flags_json or {}) + if payload.get("evaluation_report"): + reports.append(payload["evaluation_report"]) + return reports + + def list_steps(self, session_id: str) -> List[StepRecord]: + with self.SessionLocal() as session: + rows = session.execute( + select(ChapterRow).where(ChapterRow.session_id == session_id).order_by(ChapterRow.chapter_index.asc()) + ).scalars() + results = [] + for row in rows: + plan = dict(row.plan_json or {}) + if plan.get("step_record"): + step_record = StepRecord.from_dict(plan["step_record"]) + else: + replay_payload = _replay_payload_from_plan(plan) + step_record = _step_record_from_replay_payload(replay_payload) + if step_record is not None: + results.append(step_record) + return results + + def count_story_chapters(self, session_id: str) -> int: + with self.SessionLocal() as session: + return int( + session.execute( + select(func.count()).select_from(ChapterRow).where(ChapterRow.session_id == session_id) + ).scalar() + or 0 + ) + + def list_story_chapter_payloads( + self, + session_id: str, + *, + start_chapter: Optional[int] = None, + end_chapter: Optional[int] = None, + limit: Optional[int] = None, + latest: bool = False, + ) -> List[Dict[str, Any]]: + start_value = int(start_chapter) if start_chapter is not None else None + end_value = int(end_chapter) if end_chapter is not None else None + limit_value = max(1, min(500, int(limit))) if limit is not None else None + use_latest_order = bool(latest and limit_value is not None and start_value is None and end_value is None) + with self.SessionLocal() as session: + dialect_name = getattr(getattr(session, "bind", None), "dialect", None) + if getattr(dialect_name, "name", "") == "sqlite": + def _coerce_json(value: Any, fallback: Any) -> Any: + if value is None or value == "": + return fallback + if isinstance(value, (dict, list)): + return value + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return fallback + return fallback + + def _json_mapping(value: Any) -> Dict[str, Any]: + payload = _coerce_json(value, {}) + return dict(payload) if isinstance(payload, dict) else {} + + def _json_list(value: Any) -> List[Any]: + payload = _coerce_json(value, []) + return list(payload) if isinstance(payload, list) else [] + + stmt = select( + ChapterRow.chapter_id, + ChapterRow.session_id, + ChapterRow.world_version_id, + ChapterRow.chapter_index, + func.coalesce( + func.json_extract(ChapterRow.plan_json, "$.replay.reader_view"), + func.json_extract(ChapterRow.plan_json, "$.step_record.reader_view"), + ).label("reader_view_json"), + func.coalesce( + func.json_extract(ChapterRow.plan_json, "$.replay.state_before"), + func.json_extract(ChapterRow.plan_json, "$.step_record.state_before"), + ).label("state_before_json"), + func.coalesce( + func.json_extract(ChapterRow.plan_json, "$.replay.state_after"), + func.json_extract(ChapterRow.plan_json, "$.step_record.state_after"), + ).label("state_after_json"), + func.coalesce( + func.json_extract(ChapterRow.plan_json, "$.replay.scene_beats"), + func.json_extract(ChapterRow.plan_json, "$.step_record.scene_beats"), + ).label("scene_beats_json"), + func.coalesce( + func.json_extract(ChapterRow.plan_json, "$.replay.critic_trace"), + func.json_extract(ChapterRow.plan_json, "$.step_record.critic_trace"), + ).label("critic_trace_json"), + func.coalesce( + func.json_extract(ChapterRow.plan_json, "$.replay.chosen_event"), + func.json_extract(ChapterRow.plan_json, "$.step_record.chosen_event"), + ).label("chosen_event_json"), + func.coalesce( + func.json_extract(ChapterRow.plan_json, "$.replay.rendered_scene"), + func.json_extract(ChapterRow.plan_json, "$.step_record.rendered_scene"), + ).label("rendered_scene_json"), + func.coalesce( + func.json_extract(ChapterRow.plan_json, "$.replay.promise_ledger_snapshot"), + func.json_extract(ChapterRow.plan_json, "$.step_record.promise_ledger_snapshot"), + ).label("promise_ledger_json"), + ChapterRow.rendered_body, + ChapterRow.choices_json, + ChapterRow.created_at, + ).where(ChapterRow.session_id == session_id) + if start_value is not None: + stmt = stmt.where(ChapterRow.chapter_index >= start_value) + if end_value is not None: + stmt = stmt.where(ChapterRow.chapter_index <= end_value) + stmt = stmt.order_by(ChapterRow.chapter_index.desc() if use_latest_order else ChapterRow.chapter_index.asc()) + if limit_value is not None: + stmt = stmt.limit(limit_value) + rows = list(session.execute(stmt).all()) + if use_latest_order: + rows.reverse() + results: List[Dict[str, Any]] = [] + for row in rows: + reader_view = _json_mapping(row.reader_view_json) + if row.rendered_body and not reader_view.get("body"): + reader_view["body"] = row.rendered_body + choices = _coerce_json(row.choices_json, []) if isinstance(row.choices_json, str) else row.choices_json + if choices and not reader_view.get("choices"): + reader_view["choices"] = list(choices or []) + reader_view.setdefault("chapter_index", row.chapter_index) + reader_view.setdefault("chapter_title", f"第 {row.chapter_index} 章") + reader_view = repair_reader_view_for_display(reader_view) + results.append( + { + "chapter_id": row.chapter_id, + "session_id": row.session_id, + "world_version_id": row.world_version_id, + "chapter_index": row.chapter_index, + "reader_view": reader_view, + "state_before": _json_mapping(row.state_before_json), + "state_after": _json_mapping(row.state_after_json), + "scene_beats": _json_list(row.scene_beats_json), + "critic_trace": _json_list(row.critic_trace_json), + "chosen_event": _json_mapping(row.chosen_event_json), + "rendered_scene": _json_mapping(row.rendered_scene_json), + "promise_ledger_snapshot": _json_list(row.promise_ledger_json), + "created_at": row.created_at, + } + ) + return results + + stmt = select(ChapterRow).where(ChapterRow.session_id == session_id) + if start_value is not None: + stmt = stmt.where(ChapterRow.chapter_index >= start_value) + if end_value is not None: + stmt = stmt.where(ChapterRow.chapter_index <= end_value) + stmt = stmt.order_by(ChapterRow.chapter_index.desc() if use_latest_order else ChapterRow.chapter_index.asc()) + if limit_value is not None: + stmt = stmt.limit(limit_value) + rows = list(session.execute(stmt).scalars()) + if use_latest_order: + rows.reverse() + results: List[Dict[str, Any]] = [] + for row in rows: + replay_payload = _replay_payload_from_plan(dict(row.plan_json or {})) + step_record = replay_payload + reader_view = dict(step_record.get("reader_view") or {}) + if row.rendered_body and not reader_view.get("body"): + reader_view["body"] = row.rendered_body + if row.choices_json and not reader_view.get("choices"): + reader_view["choices"] = list(row.choices_json or []) + reader_view.setdefault("chapter_index", row.chapter_index) + reader_view.setdefault("chapter_title", f"第 {row.chapter_index} 章") + reader_view = repair_reader_view_for_display(reader_view) + results.append( + { + "chapter_id": row.chapter_id, + "session_id": row.session_id, + "world_version_id": row.world_version_id, + "chapter_index": row.chapter_index, + "reader_view": reader_view, + "state_before": dict(step_record.get("state_before") or {}), + "state_after": dict(step_record.get("state_after") or {}), + "scene_beats": list(step_record.get("scene_beats") or []), + "critic_trace": step_record.get("critic_trace") or {}, + "chosen_event": dict(step_record.get("chosen_event") or {}), + "rendered_scene": dict(step_record.get("rendered_scene") or {}), + "promise_ledger_snapshot": list(step_record.get("promise_ledger_snapshot") or []), + "created_at": row.created_at, + } + ) + return results + + def get_latest_step(self, session_id: str) -> Optional[StepRecord]: + with self.SessionLocal() as session: + row = session.execute( + select(ChapterRow) + .where(ChapterRow.session_id == session_id) + .order_by(ChapterRow.chapter_index.desc()) + .limit(1) + ).scalar_one_or_none() + if row is None: + return None + plan = dict(row.plan_json or {}) + if plan.get("step_record"): + return StepRecord.from_dict(plan["step_record"]) + replay_payload = _replay_payload_from_plan(plan) + return _step_record_from_replay_payload(replay_payload) + + def get_replay( + self, + session_id: str, + *, + start_chapter: Optional[int] = None, + end_chapter: Optional[int] = None, + limit: Optional[int] = None, + latest: bool = False, + ) -> Dict[str, Any]: + session_record = self.get_session(session_id) + chapter_payloads = self.list_story_chapter_payloads( + session_id, + start_chapter=start_chapter, + end_chapter=end_chapter, + limit=limit, + latest=latest, + ) + evaluation_reports = self.list_evaluation_reports(session_id=session_id) + event_trace = [dict(item.get("chosen_event") or {}) for item in chapter_payloads if item.get("chosen_event")] + reader_views = [dict(item.get("reader_view") or {}) for item in chapter_payloads if item.get("reader_view")] + state_snapshots = [session_record.initial_state.to_dict()] + [ + dict(item.get("state_after") or {}) for item in chapter_payloads if item.get("state_after") + ] + return { + "session": session_record.to_dict(), + "full_timeline": [str(event.get("title") or "") for event in event_trace if event.get("title")], + "event_trace": event_trace, + "reader_views": reader_views, + "critic_trace": [item.get("critic_trace") or [] for item in chapter_payloads], + "state_snapshots": state_snapshots, + "promise_ledger_snapshots": [list(item.get("promise_ledger_snapshot") or []) for item in chapter_payloads], + "rendered_scenes": [dict(item.get("rendered_scene") or {}) for item in chapter_payloads if item.get("rendered_scene")], + "evaluation_reports": evaluation_reports, + "replay_projection": { + "schema_version": "reader_replay_projection/v1", + "is_windowed": any(value is not None for value in [start_chapter, end_chapter, limit]) or bool(latest), + "start_chapter": start_chapter, + "end_chapter": end_chapter, + "limit": limit, + "latest": bool(latest), + "returned_chapters": len(chapter_payloads), + "total_chapters": self.count_story_chapters(session_id), + "first_chapter": int(chapter_payloads[0]["chapter_index"]) if chapter_payloads else None, + "last_chapter": int(chapter_payloads[-1]["chapter_index"]) if chapter_payloads else None, + }, + } + + def delete_session(self, session_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(SessionRow, session_id) + if row is None: + raise KeyError("unknown_session:%s" % session_id) + chapter_rows = session.execute(select(ChapterRow).where(ChapterRow.session_id == session_id)).scalars() + deleted_steps = 0 + for chapter in chapter_rows: + session.delete(chapter) + deleted_steps += 1 + session.delete(row) + session.commit() + return {"session_id": session_id, "deleted_steps": deleted_steps} + + # Review / publish / rollback + def save_review_record(self, review: Dict[str, Any]) -> Dict[str, Any]: + payload = { + "review_id": review.get("review_id") or "review_%s" % uuid4().hex[:12], + "asset_type": review["asset_type"], + "asset_id": review["asset_id"], + "status": review["status"], + "reviewer_id": review.get("reviewer_id"), + "risk_rating": review.get("risk_rating"), + "notes": review.get("notes"), + } + now = utcnow_iso() + with self.SessionLocal() as session: + row = session.get(ReviewRecordRow, payload["review_id"]) + if row is None: + row = ReviewRecordRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.asset_type = payload["asset_type"] + row.asset_id = payload["asset_id"] + row.status = payload["status"] + row.reviewer_id = payload["reviewer_id"] + row.risk_rating = payload["risk_rating"] + row.notes = payload["notes"] + row.updated_at = now + session.commit() + payload["created_at"] = now + payload["updated_at"] = now + return payload + + def save_author_comment_thread(self, thread: Dict[str, Any]) -> Dict[str, Any]: + payload = { + "thread_id": thread.get("thread_id") or "athread_%s" % uuid4().hex[:12], + "world_version_id": thread["world_version_id"], + "revision_id": thread.get("revision_id"), + "anchor_type": thread["anchor_type"], + "anchor_key": thread["anchor_key"], + "status": thread.get("status", "open"), + "severity": thread.get("severity", "normal"), + "assignee_id": thread.get("assignee_id"), + "created_by": thread["created_by"], + } + now = utcnow_iso() + with self.SessionLocal() as session: + row = session.get(AuthorCommentThreadRow, payload["thread_id"]) + if row is None: + row = AuthorCommentThreadRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.world_version_id = payload["world_version_id"] + row.revision_id = payload["revision_id"] + row.anchor_type = payload["anchor_type"] + row.anchor_key = payload["anchor_key"] + row.status = payload["status"] + row.severity = payload["severity"] + row.assignee_id = payload["assignee_id"] + row.created_by = payload["created_by"] + row.updated_at = now + session.commit() + payload["created_at"] = now + payload["updated_at"] = now + return payload + + def list_author_comment_threads( + self, + *, + world_version_id: Optional[str] = None, + revision_id: Optional[str] = None, + status: Optional[str] = None, + anchor_type: Optional[str] = None, + assignee_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(AuthorCommentThreadRow).order_by(desc(AuthorCommentThreadRow.updated_at)) + if world_version_id is not None: + stmt = stmt.where(AuthorCommentThreadRow.world_version_id == world_version_id) + if revision_id is not None: + stmt = stmt.where(AuthorCommentThreadRow.revision_id == revision_id) + if status is not None: + stmt = stmt.where(AuthorCommentThreadRow.status == status) + if anchor_type is not None: + stmt = stmt.where(AuthorCommentThreadRow.anchor_type == anchor_type) + if assignee_id is not None: + stmt = stmt.where(AuthorCommentThreadRow.assignee_id == assignee_id) + rows = session.execute(stmt).scalars() + return [ + { + "thread_id": row.thread_id, + "world_version_id": row.world_version_id, + "revision_id": row.revision_id, + "anchor_type": row.anchor_type, + "anchor_key": row.anchor_key, + "status": row.status, + "severity": row.severity, + "assignee_id": row.assignee_id, + "created_by": row.created_by, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + for row in rows + ] + + def get_author_comment_thread(self, thread_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(AuthorCommentThreadRow, thread_id) + if row is None: + raise KeyError("unknown_author_comment_thread:%s" % thread_id) + return { + "thread_id": row.thread_id, + "world_version_id": row.world_version_id, + "revision_id": row.revision_id, + "anchor_type": row.anchor_type, + "anchor_key": row.anchor_key, + "status": row.status, + "severity": row.severity, + "assignee_id": row.assignee_id, + "created_by": row.created_by, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def save_author_comment_message(self, message: Dict[str, Any]) -> Dict[str, Any]: + payload = { + "message_id": message.get("message_id") or "acomment_%s" % uuid4().hex[:12], + "thread_id": message["thread_id"], + "actor_id": message["actor_id"], + "actor_role": message["actor_role"], + "body": message["body"], + } + now = utcnow_iso() + with self.SessionLocal() as session: + row = session.get(AuthorCommentMessageRow, payload["message_id"]) + if row is None: + row = AuthorCommentMessageRow(created_at=now, **payload) + session.add(row) + else: + row.thread_id = payload["thread_id"] + row.actor_id = payload["actor_id"] + row.actor_role = payload["actor_role"] + row.body = payload["body"] + thread_row = session.get(AuthorCommentThreadRow, payload["thread_id"]) + if thread_row is not None: + thread_row.updated_at = now + session.commit() + payload["created_at"] = now + return payload + + def list_author_comment_messages(self, *, thread_id: str) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = ( + select(AuthorCommentMessageRow) + .where(AuthorCommentMessageRow.thread_id == thread_id) + .order_by(AuthorCommentMessageRow.created_at.asc()) + ) + rows = session.execute(stmt).scalars() + return [ + { + "message_id": row.message_id, + "thread_id": row.thread_id, + "actor_id": row.actor_id, + "actor_role": row.actor_role, + "body": row.body, + "created_at": row.created_at, + } + for row in rows + ] + + def save_author_approval_record(self, approval: Dict[str, Any]) -> Dict[str, Any]: + payload = { + "approval_id": approval.get("approval_id") or "approval_%s" % uuid4().hex[:12], + "world_version_id": approval["world_version_id"], + "revision_id": approval.get("revision_id"), + "status": approval["status"], + "reviewer_id": approval["reviewer_id"], + "reason": approval["reason"], + } + now = utcnow_iso() + with self.SessionLocal() as session: + row = session.get(AuthorApprovalRecordRow, payload["approval_id"]) + if row is None: + row = AuthorApprovalRecordRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.world_version_id = payload["world_version_id"] + row.revision_id = payload["revision_id"] + row.status = payload["status"] + row.reviewer_id = payload["reviewer_id"] + row.reason = payload["reason"] + row.updated_at = now + session.commit() + payload["created_at"] = now + payload["updated_at"] = now + return payload + + def list_author_approval_records( + self, + *, + world_version_id: Optional[str] = None, + revision_id: Optional[str] = None, + status: Optional[str] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(AuthorApprovalRecordRow).order_by(desc(AuthorApprovalRecordRow.updated_at)) + if world_version_id is not None: + stmt = stmt.where(AuthorApprovalRecordRow.world_version_id == world_version_id) + if revision_id is not None: + stmt = stmt.where(AuthorApprovalRecordRow.revision_id == revision_id) + if status is not None: + stmt = stmt.where(AuthorApprovalRecordRow.status == status) + rows = session.execute(stmt).scalars() + return [ + { + "approval_id": row.approval_id, + "world_version_id": row.world_version_id, + "revision_id": row.revision_id, + "status": row.status, + "reviewer_id": row.reviewer_id, + "reason": row.reason, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + for row in rows + ] + + def save_author_notification(self, notification: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "notification_id": notification.get("notification_id") or "anotify_%s" % uuid4().hex[:12], + "world_version_id": notification["world_version_id"], + "thread_id": notification.get("thread_id"), "approval_id": notification.get("approval_id"), "recipient_id": notification["recipient_id"], "recipient_role": notification.get("recipient_role", "reviewer"), @@ -763,428 +3492,4515 @@ def save_author_notification(self, notification: Dict[str, Any]) -> Dict[str, An "read_at": notification.get("read_at"), } with self.SessionLocal() as session: - row = session.get(AuthorNotificationRow, payload["notification_id"]) + row = session.get(AuthorNotificationRow, payload["notification_id"]) + if row is None: + row = AuthorNotificationRow(created_at=now, updated_at=now, **payload) + session.add(row) + created_at = now + else: + row.world_version_id = payload["world_version_id"] + row.thread_id = payload["thread_id"] + row.approval_id = payload["approval_id"] + row.recipient_id = payload["recipient_id"] + row.recipient_role = payload["recipient_role"] + row.notification_type = payload["notification_type"] + row.status = payload["status"] + row.actor_id = payload["actor_id"] + row.actor_role = payload["actor_role"] + row.title = payload["title"] + row.body = payload["body"] + row.anchor_type = payload["anchor_type"] + row.anchor_key = payload["anchor_key"] + row.metadata_json = payload["metadata_json"] + row.read_at = payload["read_at"] + row.updated_at = now + created_at = row.created_at + session.commit() + payload["created_at"] = created_at + payload["updated_at"] = now + return payload + + def save_author_thread_watcher(self, watcher: Dict[str, Any]) -> Dict[str, Any]: + existing = self.list_author_thread_watchers( + thread_id=watcher["thread_id"], + watcher_id=watcher["watcher_id"], + ) + if existing: + return existing[0] + payload = { + "watcher_record_id": watcher.get("watcher_record_id") or "awatcher_%s" % uuid4().hex[:12], + "thread_id": watcher["thread_id"], + "watcher_id": watcher["watcher_id"], + "added_by": watcher["added_by"], + } + now = utcnow_iso() + with self.SessionLocal() as session: + session.add(AuthorThreadWatcherRow(created_at=now, **payload)) + session.commit() + payload["created_at"] = now + return payload + + def save_author_draft_watcher(self, watcher: Dict[str, Any]) -> Dict[str, Any]: + existing = self.list_author_draft_watchers( + world_version_id=watcher["world_version_id"], + watcher_id=watcher["watcher_id"], + ) + if existing: + return existing[0] + payload = { + "watcher_record_id": watcher.get("watcher_record_id") or "adwatcher_%s" % uuid4().hex[:12], + "world_version_id": watcher["world_version_id"], + "watcher_id": watcher["watcher_id"], + "added_by": watcher["added_by"], + } + now = utcnow_iso() + with self.SessionLocal() as session: + session.add(AuthorDraftWatcherRow(created_at=now, **payload)) + session.commit() + payload["created_at"] = now + return payload + + def list_author_thread_watchers( + self, + *, + thread_id: Optional[str] = None, + watcher_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(AuthorThreadWatcherRow).order_by(AuthorThreadWatcherRow.created_at.asc()) + if thread_id is not None: + stmt = stmt.where(AuthorThreadWatcherRow.thread_id == thread_id) + if watcher_id is not None: + stmt = stmt.where(AuthorThreadWatcherRow.watcher_id == watcher_id) + rows = session.execute(stmt).scalars() + return [ + { + "watcher_record_id": row.watcher_record_id, + "thread_id": row.thread_id, + "watcher_id": row.watcher_id, + "added_by": row.added_by, + "created_at": row.created_at, + } + for row in rows + ] + + def delete_author_thread_watcher(self, *, thread_id: str, watcher_id: str) -> Dict[str, Any]: + removed = {"thread_id": thread_id, "watcher_id": watcher_id, "deleted": False} + with self.SessionLocal() as session: + rows = session.execute( + select(AuthorThreadWatcherRow).where( + AuthorThreadWatcherRow.thread_id == thread_id, + AuthorThreadWatcherRow.watcher_id == watcher_id, + ) + ).scalars().all() + for row in rows: + removed["deleted"] = True + removed["watcher_record_id"] = row.watcher_record_id + removed["created_at"] = row.created_at + session.delete(row) + session.commit() + return removed + + def list_author_draft_watchers( + self, + *, + world_version_id: Optional[str] = None, + watcher_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(AuthorDraftWatcherRow).order_by(AuthorDraftWatcherRow.created_at.asc()) + if world_version_id is not None: + stmt = stmt.where(AuthorDraftWatcherRow.world_version_id == world_version_id) + if watcher_id is not None: + stmt = stmt.where(AuthorDraftWatcherRow.watcher_id == watcher_id) + rows = session.execute(stmt).scalars() + return [ + { + "watcher_record_id": row.watcher_record_id, + "world_version_id": row.world_version_id, + "watcher_id": row.watcher_id, + "added_by": row.added_by, + "created_at": row.created_at, + } + for row in rows + ] + + def delete_author_draft_watcher(self, *, world_version_id: str, watcher_id: str) -> Dict[str, Any]: + removed = {"world_version_id": world_version_id, "watcher_id": watcher_id, "deleted": False} + with self.SessionLocal() as session: + rows = session.execute( + select(AuthorDraftWatcherRow).where( + AuthorDraftWatcherRow.world_version_id == world_version_id, + AuthorDraftWatcherRow.watcher_id == watcher_id, + ) + ).scalars().all() + for row in rows: + removed["deleted"] = True + removed["watcher_record_id"] = row.watcher_record_id + removed["created_at"] = row.created_at + session.delete(row) + session.commit() + return removed + + def get_author_notification(self, notification_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(AuthorNotificationRow, notification_id) + if row is None: + raise KeyError("unknown_author_notification:%s" % notification_id) + return { + "notification_id": row.notification_id, + "world_version_id": row.world_version_id, + "thread_id": row.thread_id, + "approval_id": row.approval_id, + "recipient_id": row.recipient_id, + "recipient_role": row.recipient_role, + "notification_type": row.notification_type, + "status": row.status, + "actor_id": row.actor_id, + "actor_role": row.actor_role, + "title": row.title, + "body": row.body, + "anchor_type": row.anchor_type, + "anchor_key": row.anchor_key, + "metadata_json": dict(row.metadata_json or {}), + "read_at": row.read_at, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def list_author_notifications( + self, + *, + recipient_id: Optional[str] = None, + world_version_id: Optional[str] = None, + thread_id: Optional[str] = None, + approval_id: Optional[str] = None, + status: Optional[str] = None, + notification_type: Optional[str] = None, + cursor_updated_at: Optional[str] = None, + cursor_notification_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(AuthorNotificationRow).order_by(desc(AuthorNotificationRow.updated_at), desc(AuthorNotificationRow.notification_id)) + if recipient_id is not None: + stmt = stmt.where(AuthorNotificationRow.recipient_id == recipient_id) + if world_version_id is not None: + stmt = stmt.where(AuthorNotificationRow.world_version_id == world_version_id) + if thread_id is not None: + stmt = stmt.where(AuthorNotificationRow.thread_id == thread_id) + if approval_id is not None: + stmt = stmt.where(AuthorNotificationRow.approval_id == approval_id) + if status is not None: + stmt = stmt.where(AuthorNotificationRow.status == status) + if notification_type is not None: + stmt = stmt.where(AuthorNotificationRow.notification_type == notification_type) + rows = session.execute(stmt).scalars() + items = [ + { + "notification_id": row.notification_id, + "world_version_id": row.world_version_id, + "thread_id": row.thread_id, + "approval_id": row.approval_id, + "recipient_id": row.recipient_id, + "recipient_role": row.recipient_role, + "notification_type": row.notification_type, + "status": row.status, + "actor_id": row.actor_id, + "actor_role": row.actor_role, + "title": row.title, + "body": row.body, + "anchor_type": row.anchor_type, + "anchor_key": row.anchor_key, + "metadata_json": dict(row.metadata_json or {}), + "read_at": row.read_at, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + for row in rows + ] + if cursor_updated_at is not None and cursor_notification_id is not None: + filtered = [] + for item in items: + updated_at = str(item.get("updated_at") or "") + notification_id_value = str(item.get("notification_id") or "") + if updated_at < cursor_updated_at: + filtered.append(item) + elif updated_at == cursor_updated_at and notification_id_value < cursor_notification_id: + filtered.append(item) + items = filtered + if limit is not None: + items = items[:limit] + return items + + def save_author_notification_preference(self, preference: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "preference_id": preference.get("preference_id") or "apref_%s" % uuid4().hex[:12], + "actor_id": preference["actor_id"], + "notification_type": preference["notification_type"], + "in_app_enabled": "true" if preference.get("in_app_enabled", True) else "false", + "async_mirror_enabled": "true" if preference.get("async_mirror_enabled", True) else "false", + "async_sink_name": preference.get("async_sink_name"), + "delivery_target": preference.get("delivery_target"), + } + with self.SessionLocal() as session: + stmt = select(AuthorNotificationPreferenceRow).where( + AuthorNotificationPreferenceRow.actor_id == payload["actor_id"], + AuthorNotificationPreferenceRow.notification_type == payload["notification_type"], + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + row = AuthorNotificationPreferenceRow(updated_at=now, **payload) + session.add(row) + else: + row.in_app_enabled = payload["in_app_enabled"] + row.async_mirror_enabled = payload["async_mirror_enabled"] + row.async_sink_name = payload["async_sink_name"] + row.delivery_target = payload["delivery_target"] + row.updated_at = now + payload["preference_id"] = row.preference_id + session.commit() + return { + **payload, + "in_app_enabled": payload["in_app_enabled"] == "true", + "async_mirror_enabled": payload["async_mirror_enabled"] == "true", + "updated_at": now, + } + + def list_author_notification_preferences( + self, + *, + actor_id: Optional[str] = None, + notification_type: Optional[str] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(AuthorNotificationPreferenceRow).order_by( + AuthorNotificationPreferenceRow.actor_id.asc(), + AuthorNotificationPreferenceRow.notification_type.asc(), + ) + if actor_id is not None: + stmt = stmt.where(AuthorNotificationPreferenceRow.actor_id == actor_id) + if notification_type is not None: + stmt = stmt.where(AuthorNotificationPreferenceRow.notification_type == notification_type) + rows = session.execute(stmt).scalars() + return [ + { + "preference_id": row.preference_id, + "actor_id": row.actor_id, + "notification_type": row.notification_type, + "in_app_enabled": row.in_app_enabled == "true", + "async_mirror_enabled": row.async_mirror_enabled == "true", + "async_sink_name": row.async_sink_name, + "delivery_target": row.delivery_target, + "updated_at": row.updated_at, + } + for row in rows + ] + + def save_showcase_work_like(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "showcase_like_id": payload.get("showcase_like_id") or "showlike_%s" % uuid4().hex[:12], + "world_id": payload["world_id"], + "world_version_id": payload["world_version_id"], + "account_id": payload["account_id"], + "actor_id": payload.get("actor_id"), + } + with self.SessionLocal() as session: + stmt = select(ShowcaseWorkLikeRow).where( + ShowcaseWorkLikeRow.world_id == record["world_id"], + ShowcaseWorkLikeRow.account_id == record["account_id"], + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + row = ShowcaseWorkLikeRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + row.world_version_id = record["world_version_id"] + row.actor_id = record["actor_id"] + row.updated_at = now + record["showcase_like_id"] = row.showcase_like_id + session.commit() + return { + **record, + "created_at": now, + "updated_at": now, + } + + def list_showcase_work_likes( + self, + *, + world_id: Optional[str] = None, + world_ids: Optional[List[str]] = None, + account_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ShowcaseWorkLikeRow).order_by(desc(ShowcaseWorkLikeRow.updated_at)) + if world_id is not None: + stmt = stmt.where(ShowcaseWorkLikeRow.world_id == world_id) + if world_ids: + stmt = stmt.where(ShowcaseWorkLikeRow.world_id.in_(world_ids)) + if account_id is not None: + stmt = stmt.where(ShowcaseWorkLikeRow.account_id == account_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars() + return [ + { + "showcase_like_id": row.showcase_like_id, + "world_id": row.world_id, + "world_version_id": row.world_version_id, + "account_id": row.account_id, + "actor_id": row.actor_id, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + for row in rows + ] + + def delete_showcase_work_like(self, *, world_id: str, account_id: str) -> Dict[str, Any]: + deleted = False + with self.SessionLocal() as session: + stmt = select(ShowcaseWorkLikeRow).where( + ShowcaseWorkLikeRow.world_id == world_id, + ShowcaseWorkLikeRow.account_id == account_id, + ) + row = session.execute(stmt).scalar_one_or_none() + if row is not None: + session.execute( + delete(ShowcaseWorkLikeRow).where( + ShowcaseWorkLikeRow.showcase_like_id == row.showcase_like_id, + ) + ) + deleted = True + session.commit() + return { + "world_id": world_id, + "account_id": account_id, + "deleted": deleted, + } + + def showcase_work_like_counts(self, *, world_ids: List[str]) -> Dict[str, int]: + items = self.list_showcase_work_likes(world_ids=world_ids) + counts: Dict[str, int] = {} + for item in items: + key = str(item.get("world_id") or "") + if not key: + continue + counts[key] = counts.get(key, 0) + 1 + return counts + + def save_showcase_work_comment(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "showcase_comment_id": payload.get("showcase_comment_id") or "showcomment_%s" % uuid4().hex[:12], + "world_id": payload["world_id"], + "world_version_id": payload["world_version_id"], + "account_id": payload["account_id"], + "actor_id": payload.get("actor_id"), + "author_name": payload["author_name"], + "content": payload["content"], + "status": payload.get("status", "published"), + } + with self.SessionLocal() as session: + row = ShowcaseWorkCommentRow(created_at=now, updated_at=now, **record) + session.add(row) + session.commit() + return { + **record, + "created_at": now, + "updated_at": now, + } + + def list_showcase_work_comments( + self, + *, + world_id: Optional[str] = None, + world_ids: Optional[List[str]] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + offset: int = 0, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ShowcaseWorkCommentRow).order_by(desc(ShowcaseWorkCommentRow.created_at)) + if world_id is not None: + stmt = stmt.where(ShowcaseWorkCommentRow.world_id == world_id) + if world_ids: + stmt = stmt.where(ShowcaseWorkCommentRow.world_id.in_(world_ids)) + if status is not None: + stmt = stmt.where(ShowcaseWorkCommentRow.status == status) + if offset: + stmt = stmt.offset(offset) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars() + return [ + { + "showcase_comment_id": row.showcase_comment_id, + "world_id": row.world_id, + "world_version_id": row.world_version_id, + "account_id": row.account_id, + "actor_id": row.actor_id, + "author_name": row.author_name, + "content": row.content, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + for row in rows + ] + + def showcase_work_comment_counts(self, *, world_ids: List[str], status: str = "published") -> Dict[str, int]: + items = self.list_showcase_work_comments(world_ids=world_ids, status=status) + counts: Dict[str, int] = {} + for item in items: + key = str(item.get("world_id") or "") + if not key: + continue + counts[key] = counts.get(key, 0) + 1 + return counts + + def save_showcase_work_tip(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "showcase_tip_id": payload.get("showcase_tip_id") or "showtip_%s" % uuid4().hex[:12], + "world_id": payload["world_id"], + "world_version_id": payload["world_version_id"], + "account_id": payload["account_id"], + "actor_id": payload.get("actor_id"), + "amount": int(payload["amount"]), + "wallet_type": payload.get("wallet_type", "story_credits"), + "balance_after": float(payload["balance_after"]), + } + with self.SessionLocal() as session: + row = ShowcaseWorkTipRow(created_at=now, updated_at=now, **record) + session.add(row) + session.commit() + return { + **record, + "created_at": now, + "updated_at": now, + } + + def list_showcase_work_tips( + self, + *, + world_id: Optional[str] = None, + world_ids: Optional[List[str]] = None, + account_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ShowcaseWorkTipRow).order_by(desc(ShowcaseWorkTipRow.created_at)) + if world_id is not None: + stmt = stmt.where(ShowcaseWorkTipRow.world_id == world_id) + if world_ids: + stmt = stmt.where(ShowcaseWorkTipRow.world_id.in_(world_ids)) + if account_id is not None: + stmt = stmt.where(ShowcaseWorkTipRow.account_id == account_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars() + return [ + { + "showcase_tip_id": row.showcase_tip_id, + "world_id": row.world_id, + "world_version_id": row.world_version_id, + "account_id": row.account_id, + "actor_id": row.actor_id, + "amount": row.amount, + "wallet_type": row.wallet_type, + "balance_after": row.balance_after, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + for row in rows + ] + + def showcase_work_tip_totals(self, *, world_ids: List[str]) -> Dict[str, int]: + totals: Dict[str, int] = {} + for item in self.list_showcase_work_tips(world_ids=world_ids): + key = str(item.get("world_id") or "") + if not key: + continue + totals[key] = totals.get(key, 0) + int(item.get("amount") or 0) + return totals + + def save_showcase_work_view(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "showcase_view_id": payload.get("showcase_view_id") or "showview_%s" % uuid4().hex[:12], + "world_id": payload["world_id"], + "world_version_id": payload["world_version_id"], + "account_id": payload.get("account_id"), + "viewer_key": payload["viewer_key"], + "event_type": payload.get("event_type", "view"), + } + with self.SessionLocal() as session: + stmt = select(ShowcaseWorkViewRow).where( + ShowcaseWorkViewRow.world_id == record["world_id"], + ShowcaseWorkViewRow.viewer_key == record["viewer_key"], + ShowcaseWorkViewRow.event_type == record["event_type"], + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + row = ShowcaseWorkViewRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + row.world_version_id = record["world_version_id"] + row.account_id = record["account_id"] + row.updated_at = now + record["showcase_view_id"] = row.showcase_view_id + session.commit() + return _showcase_work_view_payload(row) + + def list_showcase_work_views( + self, + *, + world_id: Optional[str] = None, + world_ids: Optional[List[str]] = None, + account_id: Optional[str] = None, + event_type: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ShowcaseWorkViewRow).order_by(desc(ShowcaseWorkViewRow.updated_at)) + if world_id is not None: + stmt = stmt.where(ShowcaseWorkViewRow.world_id == world_id) + if world_ids: + stmt = stmt.where(ShowcaseWorkViewRow.world_id.in_(world_ids)) + if account_id is not None: + stmt = stmt.where(ShowcaseWorkViewRow.account_id == account_id) + if event_type is not None: + stmt = stmt.where(ShowcaseWorkViewRow.event_type == event_type) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars() + return [_showcase_work_view_payload(row) for row in rows] + + def showcase_work_view_counts(self, *, world_ids: List[str], event_type: str = "view") -> Dict[str, int]: + counts: Dict[str, int] = {} + for item in self.list_showcase_work_views(world_ids=world_ids, event_type=event_type): + key = str(item.get("world_id") or "") + if not key: + continue + counts[key] = counts.get(key, 0) + 1 + return counts + + def save_library_work_favorite(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "favorite_id": payload.get("favorite_id") or "libfav_%s" % uuid4().hex[:12], + "account_id": payload["account_id"], + "work_id": payload["work_id"], + "work_kind": payload["work_kind"], + "title_snapshot": payload.get("title_snapshot"), + } + with self.SessionLocal() as session: + stmt = select(LibraryWorkFavoriteRow).where( + LibraryWorkFavoriteRow.account_id == record["account_id"], + LibraryWorkFavoriteRow.work_id == record["work_id"], + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + row = LibraryWorkFavoriteRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + row.work_kind = record["work_kind"] + row.title_snapshot = record["title_snapshot"] + row.updated_at = now + record["favorite_id"] = row.favorite_id + session.commit() + return _library_work_favorite_payload(row) + + def list_library_work_favorites( + self, + *, + account_id: Optional[str] = None, + work_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(LibraryWorkFavoriteRow).order_by(desc(LibraryWorkFavoriteRow.updated_at)) + if account_id is not None: + stmt = stmt.where(LibraryWorkFavoriteRow.account_id == account_id) + if work_id is not None: + stmt = stmt.where(LibraryWorkFavoriteRow.work_id == work_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars() + return [_library_work_favorite_payload(row) for row in rows] + + def delete_library_work_favorite(self, *, account_id: str, work_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + stmt = select(LibraryWorkFavoriteRow).where( + LibraryWorkFavoriteRow.account_id == account_id, + LibraryWorkFavoriteRow.work_id == work_id, + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + return { + "favorite_id": None, + "account_id": account_id, + "work_id": work_id, + "deleted": False, + } + payload = _library_work_favorite_payload(row) + session.delete(row) + session.commit() + return { + **payload, + "deleted": True, + } + + def save_library_follow(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "follow_id": payload.get("follow_id") or "libfollow_%s" % uuid4().hex[:12], + "account_id": payload["account_id"], + "target_type": payload["target_type"], + "target_id": payload["target_id"], + } + with self.SessionLocal() as session: + stmt = select(LibraryFollowRow).where( + LibraryFollowRow.account_id == record["account_id"], + LibraryFollowRow.target_type == record["target_type"], + LibraryFollowRow.target_id == record["target_id"], + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + row = LibraryFollowRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + row.updated_at = now + record["follow_id"] = row.follow_id + session.commit() + return _library_follow_payload(row) + + def list_library_follows( + self, + *, + account_id: Optional[str] = None, + target_type: Optional[str] = None, + target_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(LibraryFollowRow).order_by(desc(LibraryFollowRow.updated_at)) + if account_id is not None: + stmt = stmt.where(LibraryFollowRow.account_id == account_id) + if target_type is not None: + stmt = stmt.where(LibraryFollowRow.target_type == target_type) + if target_id is not None: + stmt = stmt.where(LibraryFollowRow.target_id == target_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars() + return [_library_follow_payload(row) for row in rows] + + def delete_library_follow(self, *, account_id: str, target_type: str, target_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + stmt = select(LibraryFollowRow).where( + LibraryFollowRow.account_id == account_id, + LibraryFollowRow.target_type == target_type, + LibraryFollowRow.target_id == target_id, + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + return { + "follow_id": None, + "account_id": account_id, + "target_type": target_type, + "target_id": target_id, + "deleted": False, + } + payload = _library_follow_payload(row) + session.delete(row) + session.commit() + return { + **payload, + "deleted": True, + } + + def save_generated_media_asset(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "asset_id": str(payload.get("asset_id") or "media_%s" % uuid4().hex[:12]), + "asset_kind": str(payload["asset_kind"]), + "owner_scope": str(payload["owner_scope"]), + "owner_id": str(payload["owner_id"]), + "world_id": str(payload.get("world_id") or "") or None, + "world_version_id": str(payload.get("world_version_id") or "") or None, + "session_id": str(payload.get("session_id") or "") or None, + "chapter_index": int(payload["chapter_index"]) if payload.get("chapter_index") is not None else None, + "reader_id": str(payload.get("reader_id") or "") or None, + "storage_bucket": str(payload.get("storage_bucket") or "") or None, + "storage_key": str(payload.get("storage_key") or "") or None, + "mime_type": str(payload.get("mime_type") or "") or None, + "width": int(payload["width"]) if payload.get("width") is not None else None, + "height": int(payload["height"]) if payload.get("height") is not None else None, + "visibility": str(payload.get("visibility") or "private"), + "generation_status": str(payload.get("generation_status") or "queued"), + "model_name": str(payload.get("model_name") or "") or None, + "prompt_version": str(payload.get("prompt_version") or "") or None, + "source_fingerprint": str(payload.get("source_fingerprint") or "") or None, + "prompt_trace_json": dict(payload.get("prompt_trace_json") or {}), + "error": str(payload.get("error") or "") or None, + } + with self.SessionLocal() as session: + row = session.get(GeneratedMediaAssetRow, record["asset_id"]) + if row is None: + row = GeneratedMediaAssetRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + row.asset_kind = record["asset_kind"] + row.owner_scope = record["owner_scope"] + row.owner_id = record["owner_id"] + row.world_id = record["world_id"] + row.world_version_id = record["world_version_id"] + row.session_id = record["session_id"] + row.chapter_index = record["chapter_index"] + row.reader_id = record["reader_id"] + row.storage_bucket = record["storage_bucket"] + row.storage_key = record["storage_key"] + row.mime_type = record["mime_type"] + row.width = record["width"] + row.height = record["height"] + row.visibility = record["visibility"] + row.generation_status = record["generation_status"] + row.model_name = record["model_name"] + row.prompt_version = record["prompt_version"] + row.source_fingerprint = record["source_fingerprint"] + row.prompt_trace_json = record["prompt_trace_json"] + row.error = record["error"] + row.updated_at = now + session.commit() + return _generated_media_asset_payload(row) + + def get_generated_media_asset(self, asset_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(GeneratedMediaAssetRow, asset_id) + if row is None: + raise KeyError("unknown_generated_media_asset:%s" % asset_id) + return _generated_media_asset_payload(row) + + def list_generated_media_assets( + self, + *, + asset_kind: Optional[str] = None, + owner_scope: Optional[str] = None, + owner_id: Optional[str] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + generation_status: Optional[str] = None, + source_fingerprint: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(GeneratedMediaAssetRow).order_by(desc(GeneratedMediaAssetRow.updated_at)) + if asset_kind is not None: + stmt = stmt.where(GeneratedMediaAssetRow.asset_kind == asset_kind) + if owner_scope is not None: + stmt = stmt.where(GeneratedMediaAssetRow.owner_scope == owner_scope) + if owner_id is not None: + stmt = stmt.where(GeneratedMediaAssetRow.owner_id == owner_id) + if world_version_id is not None: + stmt = stmt.where(GeneratedMediaAssetRow.world_version_id == world_version_id) + if session_id is not None: + stmt = stmt.where(GeneratedMediaAssetRow.session_id == session_id) + if generation_status is not None: + stmt = stmt.where(GeneratedMediaAssetRow.generation_status == generation_status) + if source_fingerprint is not None: + stmt = stmt.where(GeneratedMediaAssetRow.source_fingerprint == source_fingerprint) + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_generated_media_asset_payload(row) for row in rows] + + def latest_generated_media_asset( + self, + *, + asset_kind: str, + owner_scope: str, + owner_id: str, + generation_status: Optional[str] = "succeeded", + default: Optional[Dict[str, Any]] = ..., + ) -> Optional[Dict[str, Any]]: + items = self.list_generated_media_assets( + asset_kind=asset_kind, + owner_scope=owner_scope, + owner_id=owner_id, + generation_status=generation_status, + limit=1, + ) + if items: + return items[0] + if default is ...: + raise KeyError( + "unknown_generated_media_asset_latest:%s:%s:%s" % (asset_kind, owner_scope, owner_id) + ) + return default + + def save_author_project_graph(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "project_id": str(payload.get("project_id") or payload["world_version_id"]), + "world_version_id": str(payload["world_version_id"]), + "account_id": str(payload["account_id"]), + "engine": str(payload.get("engine") or "balanced"), + "enabled_rule_ids_json": [str(item) for item in list(payload.get("enabled_rule_ids") or []) if str(item).strip()], + "nodes_json": list(payload.get("nodes") or []), + "connections_json": list(payload.get("connections") or []), + "metadata_json": dict(payload.get("metadata_json") or {}), + } + with self.SessionLocal() as session: + stmt = select(AuthorProjectGraphRow).where(AuthorProjectGraphRow.project_id == record["project_id"]) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + row = AuthorProjectGraphRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + row.world_version_id = record["world_version_id"] + row.account_id = record["account_id"] + row.engine = record["engine"] + row.enabled_rule_ids_json = record["enabled_rule_ids_json"] + row.nodes_json = record["nodes_json"] + row.connections_json = record["connections_json"] + row.metadata_json = record["metadata_json"] + row.updated_at = now + session.commit() + return _author_project_graph_payload(row) + + def get_author_project_graph( + self, + project_id: str, + *, + default: Optional[Dict[str, Any]] = ..., + ) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(AuthorProjectGraphRow, project_id) + if row is None: + if default is ...: + raise KeyError("unknown_author_project_graph:%s" % project_id) + return default + return _author_project_graph_payload(row) + + def save_story_session_bookmark(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "bookmark_id": payload.get("bookmark_id") or "storybookmark_%s" % uuid4().hex[:12], + "session_id": payload["session_id"], + "account_id": payload["account_id"], + "node_id": payload["node_id"], + } + with self.SessionLocal() as session: + stmt = select(StorySessionBookmarkRow).where( + StorySessionBookmarkRow.session_id == record["session_id"], + StorySessionBookmarkRow.account_id == record["account_id"], + StorySessionBookmarkRow.node_id == record["node_id"], + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + row = StorySessionBookmarkRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + record["bookmark_id"] = row.bookmark_id + row.updated_at = now + session.commit() + return { + **record, + "created_at": now, + "updated_at": now, + } + + def delete_story_session_bookmark( + self, + *, + session_id: str, + account_id: str, + node_id: str, + ) -> Dict[str, Any]: + with self.SessionLocal() as session: + stmt = select(StorySessionBookmarkRow).where( + StorySessionBookmarkRow.session_id == session_id, + StorySessionBookmarkRow.account_id == account_id, + StorySessionBookmarkRow.node_id == node_id, + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + return { + "bookmark_id": None, + "session_id": session_id, + "account_id": account_id, + "node_id": node_id, + "deleted": False, + } + payload = { + "bookmark_id": row.bookmark_id, + "session_id": row.session_id, + "account_id": row.account_id, + "node_id": row.node_id, + "created_at": row.created_at, + "updated_at": row.updated_at, + "deleted": True, + } + session.delete(row) + session.commit() + return payload + + def list_story_session_bookmarks( + self, + *, + session_id: Optional[str] = None, + account_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(StorySessionBookmarkRow).order_by(desc(StorySessionBookmarkRow.updated_at)) + if session_id is not None: + stmt = stmt.where(StorySessionBookmarkRow.session_id == session_id) + if account_id is not None: + stmt = stmt.where(StorySessionBookmarkRow.account_id == account_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars() + return [ + { + "bookmark_id": row.bookmark_id, + "session_id": row.session_id, + "account_id": row.account_id, + "node_id": row.node_id, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + for row in rows + ] + + def save_story_session_share_token(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + now_dt = datetime.now(timezone.utc) + + def _parse_timestamp(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + normalized = str(value).replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + record = { + "share_token": payload.get("share_token") or "storyshare_%s" % uuid4().hex[:16], + "session_id": payload["session_id"], + "account_id": payload["account_id"], + "node_id": payload["node_id"], + "sharer_name": payload["sharer_name"], + "status": payload.get("status", "active"), + "expires_at": payload.get("expires_at"), + "revoked_at": payload.get("revoked_at"), + } + with self.SessionLocal() as session: + stmt = ( + select(StorySessionShareTokenRow) + .where( + StorySessionShareTokenRow.session_id == record["session_id"], + StorySessionShareTokenRow.account_id == record["account_id"], + StorySessionShareTokenRow.node_id == record["node_id"], + ) + .order_by(desc(StorySessionShareTokenRow.created_at)) + ) + rows = list(session.execute(stmt).scalars()) + row = None + for candidate in rows: + expires_at = _parse_timestamp(candidate.expires_at) + if candidate.status == "active" and (expires_at is None or expires_at > now_dt): + row = candidate + break + if row is None: + row = StorySessionShareTokenRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + record["share_token"] = row.share_token + record["status"] = row.status + record["expires_at"] = row.expires_at + record["revoked_at"] = row.revoked_at + row.sharer_name = record["sharer_name"] + row.updated_at = now + session.commit() + return { + **record, + "created_at": now, + "updated_at": now, + } + + def get_story_session_share_token(self, share_token: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(StorySessionShareTokenRow, share_token) + if row is None: + if default is ...: + raise KeyError("unknown_story_session_share_token") + return default + return { + "share_token": row.share_token, + "session_id": row.session_id, + "account_id": row.account_id, + "node_id": row.node_id, + "sharer_name": row.sharer_name, + "status": row.status, + "expires_at": row.expires_at, + "revoked_at": row.revoked_at, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def revoke_story_session_share_token(self, share_token: str) -> Dict[str, Any]: + now = utcnow_iso() + with self.SessionLocal() as session: + row = session.get(StorySessionShareTokenRow, share_token) + if row is None: + raise KeyError("unknown_story_session_share_token") + if row.status != "revoked": + row.status = "revoked" + row.revoked_at = now + row.updated_at = now + session.commit() + return { + "share_token": row.share_token, + "session_id": row.session_id, + "account_id": row.account_id, + "node_id": row.node_id, + "sharer_name": row.sharer_name, + "status": row.status, + "expires_at": row.expires_at, + "revoked_at": row.revoked_at, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def save_auth_identity(self, identity: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + "actor_role": identity["actor_role"], + "display_name": identity.get("display_name"), + "password_hash": identity["password_hash"], + "password_salt": identity["password_salt"], + "status": identity.get("status", "active"), + } + with self.SessionLocal() as session: + row = session.get(AuthIdentityRow, payload["actor_id"]) + if row is None: + row = AuthIdentityRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.account_id = payload["account_id"] + row.actor_role = payload["actor_role"] + row.display_name = payload["display_name"] + row.password_hash = payload["password_hash"] + row.password_salt = payload["password_salt"] + row.status = payload["status"] + row.updated_at = now + session.commit() + return { + **payload, + "created_at": now, + "updated_at": now, + } + + def get_auth_identity(self, actor_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(AuthIdentityRow, actor_id) + if row is None: + raise KeyError("unknown_auth_identity:%s" % actor_id) + return { + "actor_id": row.actor_id, + "account_id": row.account_id, + "actor_role": row.actor_role, + "display_name": row.display_name, + "password_hash": row.password_hash, + "password_salt": row.password_salt, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def get_auth_identity_by_account_id( + self, + account_id: str, + *, + default: Optional[Dict[str, Any]] = ..., + ) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = ( + select(AuthIdentityRow) + .where(AuthIdentityRow.account_id == account_id) + .order_by(desc(AuthIdentityRow.updated_at)) + .limit(1) + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + if default is ...: + raise KeyError("unknown_auth_identity_for_account:%s" % account_id) + return default + return { + "actor_id": row.actor_id, + "account_id": row.account_id, + "actor_role": row.actor_role, + "display_name": row.display_name, + "password_hash": row.password_hash, + "password_salt": row.password_salt, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def list_auth_identities( + self, + *, + actor_roles: Optional[List[str]] = None, + status: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(AuthIdentityRow) + if actor_roles: + stmt = stmt.where(AuthIdentityRow.actor_role.in_([str(item) for item in actor_roles if str(item).strip()])) + if status is not None: + stmt = stmt.where(AuthIdentityRow.status == status) + stmt = stmt.order_by(AuthIdentityRow.display_name.asc(), AuthIdentityRow.actor_id.asc()).limit(limit) + rows = session.execute(stmt).scalars().all() + return [ + { + "actor_id": row.actor_id, + "account_id": row.account_id, + "actor_role": row.actor_role, + "display_name": row.display_name, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + for row in rows + ] + + def save_auth_token(self, token: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "token_id": token.get("token_id") or "token_%s" % uuid4().hex[:12], + "actor_id": token["actor_id"], + "account_id": token.get("account_id"), + "actor_role": token["actor_role"], + "token_hash": token["token_hash"], + "status": token.get("status", "active"), + "expires_at": token.get("expires_at"), + "last_used_at": token.get("last_used_at"), + } + with self.SessionLocal() as session: + row = session.get(AuthTokenRow, payload["token_id"]) + if row is None: + row = AuthTokenRow(created_at=now, **payload) + session.add(row) + else: + row.actor_id = payload["actor_id"] + row.account_id = payload["account_id"] + row.actor_role = payload["actor_role"] + row.token_hash = payload["token_hash"] + row.status = payload["status"] + row.expires_at = payload["expires_at"] + row.last_used_at = payload["last_used_at"] + session.commit() + return { + **payload, + "created_at": now, + } + + def get_auth_token_by_hash(self, token_hash: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + stmt = select(AuthTokenRow).where(AuthTokenRow.token_hash == token_hash) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + raise KeyError("unknown_auth_token") + return { + "token_id": row.token_id, + "actor_id": row.actor_id, + "account_id": row.account_id, + "actor_role": row.actor_role, + "token_hash": row.token_hash, + "status": row.status, + "created_at": row.created_at, + "expires_at": row.expires_at, + "last_used_at": row.last_used_at, + } + + def update_auth_token(self, token_id: str, updates: Dict[str, Any]) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(AuthTokenRow, token_id) + if row is None: + raise KeyError("unknown_auth_token:%s" % token_id) + for key in ["status", "expires_at", "last_used_at", "account_id", "actor_role"]: + if key in updates: + setattr(row, key, updates[key]) + session.commit() + return { + "token_id": row.token_id, + "actor_id": row.actor_id, + "account_id": row.account_id, + "actor_role": row.actor_role, + "token_hash": row.token_hash, + "status": row.status, + "created_at": row.created_at, + "expires_at": row.expires_at, + "last_used_at": row.last_used_at, + } + + def save_soul_profile_preferences(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "actor_id": str(payload["actor_id"]), + "account_id": str(payload.get("account_id") or "") or None, + "genres_json": [str(item) for item in list(payload.get("genres") or []) if str(item).strip()], + "styles_json": [str(item) for item in list(payload.get("styles") or []) if str(item).strip()], + "privacy_mode": str(payload.get("privacy_mode") or "followers").strip() or "followers", + } + with self.SessionLocal() as session: + row = session.get(SoulProfilePreferenceRow, record["actor_id"]) + if row is None: + row = SoulProfilePreferenceRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + row.account_id = record["account_id"] + row.genres_json = record["genres_json"] + row.styles_json = record["styles_json"] + row.privacy_mode = record["privacy_mode"] + row.updated_at = now + session.commit() + return _soul_profile_preference_payload(row) + + def get_soul_profile_preferences( + self, + actor_id: str, + *, + default: Optional[Dict[str, Any]] = ..., + ) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(SoulProfilePreferenceRow, actor_id) + if row is None: + if default is ...: + raise KeyError("unknown_soul_profile_preferences:%s" % actor_id) + return default + return _soul_profile_preference_payload(row) + + def save_auth_identity_profile(self, profile: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "actor_id": profile["actor_id"], + "account_id": profile.get("account_id"), + "email_address": profile.get("email_address"), + "pending_email_address": profile.get("pending_email_address"), + "avatar_url": profile.get("avatar_url"), + "email_verified": "true" if profile.get("email_verified") else "false", + "verification_required": "true" if profile.get("verification_required") else "false", + "verification_sent_at": profile.get("verification_sent_at"), + "verified_at": profile.get("verified_at"), + "password_reset_sent_at": profile.get("password_reset_sent_at"), + "pending_email_change_requested_at": profile.get("pending_email_change_requested_at"), + "email_change_last_sent_at": profile.get("email_change_last_sent_at"), + "ui_preferences_json": dict(profile.get("ui_preferences_json") or {}) or None, + "deactivated_at": profile.get("deactivated_at"), + "deactivated_by": profile.get("deactivated_by"), + "deactivation_reason": profile.get("deactivation_reason"), + } + with self.SessionLocal() as session: + row = session.get(AuthIdentityProfileRow, payload["actor_id"]) + if row is None: + row = AuthIdentityProfileRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.account_id = payload["account_id"] + row.email_address = payload["email_address"] + row.pending_email_address = payload["pending_email_address"] + row.avatar_url = payload["avatar_url"] + row.email_verified = payload["email_verified"] + row.verification_required = payload["verification_required"] + row.verification_sent_at = payload["verification_sent_at"] + row.verified_at = payload["verified_at"] + row.password_reset_sent_at = payload["password_reset_sent_at"] + row.pending_email_change_requested_at = payload["pending_email_change_requested_at"] + row.email_change_last_sent_at = payload["email_change_last_sent_at"] + row.ui_preferences_json = payload["ui_preferences_json"] + row.deactivated_at = payload["deactivated_at"] + row.deactivated_by = payload["deactivated_by"] + row.deactivation_reason = payload["deactivation_reason"] + row.updated_at = now + session.commit() + return { + "actor_id": payload["actor_id"], + "account_id": payload["account_id"], + "email_address": payload["email_address"], + "pending_email_address": payload["pending_email_address"], + "avatar_url": payload["avatar_url"], + "email_verified": payload["email_verified"] == "true", + "verification_required": payload["verification_required"] == "true", + "verification_sent_at": payload["verification_sent_at"], + "verified_at": payload["verified_at"], + "password_reset_sent_at": payload["password_reset_sent_at"], + "pending_email_change_requested_at": payload["pending_email_change_requested_at"], + "email_change_last_sent_at": payload["email_change_last_sent_at"], + "ui_preferences_json": dict(payload["ui_preferences_json"] or {}), + "deactivated_at": payload["deactivated_at"], + "deactivated_by": payload["deactivated_by"], + "deactivation_reason": payload["deactivation_reason"], + "created_at": now, + "updated_at": now, + } + + def get_auth_identity_profile_by_email_address( + self, + email_address: str, + *, + pending: bool = False, + default: Optional[Dict[str, Any]] = ..., + ) -> Optional[Dict[str, Any]]: + normalized_email = str(email_address or "").strip().lower() + with self.SessionLocal() as session: + stmt = select(AuthIdentityProfileRow) + if pending: + stmt = stmt.where(AuthIdentityProfileRow.pending_email_address == normalized_email) + else: + stmt = stmt.where(AuthIdentityProfileRow.email_address == normalized_email) + stmt = stmt.order_by(desc(AuthIdentityProfileRow.updated_at)).limit(1) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + if default is ...: + raise KeyError("unknown_auth_identity_profile_for_email:%s" % normalized_email) + return default + return { + "actor_id": row.actor_id, + "account_id": row.account_id, + "email_address": row.email_address, + "pending_email_address": row.pending_email_address, + "avatar_url": row.avatar_url, + "email_verified": row.email_verified == "true", + "verification_required": row.verification_required == "true", + "verification_sent_at": row.verification_sent_at, + "verified_at": row.verified_at, + "password_reset_sent_at": row.password_reset_sent_at, + "pending_email_change_requested_at": row.pending_email_change_requested_at, + "email_change_last_sent_at": row.email_change_last_sent_at, + "ui_preferences_json": dict(row.ui_preferences_json or {}), + "deactivated_at": row.deactivated_at, + "deactivated_by": row.deactivated_by, + "deactivation_reason": row.deactivation_reason, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def get_auth_identity_profile(self, actor_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(AuthIdentityProfileRow, actor_id) + if row is None: + if default is ...: + raise KeyError("unknown_auth_identity_profile:%s" % actor_id) + return default + return { + "actor_id": row.actor_id, + "account_id": row.account_id, + "email_address": row.email_address, + "pending_email_address": row.pending_email_address, + "avatar_url": row.avatar_url, + "email_verified": row.email_verified == "true", + "verification_required": row.verification_required == "true", + "verification_sent_at": row.verification_sent_at, + "verified_at": row.verified_at, + "password_reset_sent_at": row.password_reset_sent_at, + "pending_email_change_requested_at": row.pending_email_change_requested_at, + "email_change_last_sent_at": row.email_change_last_sent_at, + "ui_preferences_json": dict(row.ui_preferences_json or {}), + "deactivated_at": row.deactivated_at, + "deactivated_by": row.deactivated_by, + "deactivation_reason": row.deactivation_reason, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def save_auth_flow_token(self, token: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "flow_token_id": token.get("flow_token_id") or "flow_%s" % uuid4().hex[:12], + "actor_id": token["actor_id"], + "account_id": token.get("account_id"), + "flow_type": token["flow_type"], + "token_hash": token["token_hash"], + "status": token.get("status", "active"), + "payload_json": dict(token.get("payload_json") or {}), + "expires_at": token.get("expires_at"), + "consumed_at": token.get("consumed_at"), + } + with self.SessionLocal() as session: + row = session.get(AuthFlowTokenRow, payload["flow_token_id"]) + if row is None: + row = AuthFlowTokenRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.actor_id = payload["actor_id"] + row.account_id = payload["account_id"] + row.flow_type = payload["flow_type"] + row.token_hash = payload["token_hash"] + row.status = payload["status"] + row.payload_json = payload["payload_json"] + row.expires_at = payload["expires_at"] + row.consumed_at = payload["consumed_at"] + row.updated_at = now + session.commit() + return { + **payload, + "created_at": now, + "updated_at": now, + } + + def get_auth_flow_token_by_hash( + self, + token_hash: str, + *, + flow_type: Optional[str] = None, + default: Optional[Dict[str, Any]] = ..., + ) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(AuthFlowTokenRow).where(AuthFlowTokenRow.token_hash == token_hash) + if flow_type is not None: + stmt = stmt.where(AuthFlowTokenRow.flow_type == flow_type) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + if default is ...: + raise KeyError("unknown_auth_flow_token") + return default + return { + "flow_token_id": row.flow_token_id, + "actor_id": row.actor_id, + "account_id": row.account_id, + "flow_type": row.flow_type, + "token_hash": row.token_hash, + "status": row.status, + "payload_json": dict(row.payload_json or {}), + "expires_at": row.expires_at, + "consumed_at": row.consumed_at, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def update_auth_flow_token(self, flow_token_id: str, updates: Dict[str, Any]) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(AuthFlowTokenRow, flow_token_id) + if row is None: + raise KeyError("unknown_auth_flow_token:%s" % flow_token_id) + for key in ["status", "payload_json", "expires_at", "consumed_at", "account_id"]: + if key in updates: + value = updates[key] + if key == "payload_json" and value is not None: + value = dict(value) + setattr(row, key, value) + row.updated_at = utcnow_iso() + session.commit() + return { + "flow_token_id": row.flow_token_id, + "actor_id": row.actor_id, + "account_id": row.account_id, + "flow_type": row.flow_type, + "token_hash": row.token_hash, + "status": row.status, + "payload_json": dict(row.payload_json or {}), + "expires_at": row.expires_at, + "consumed_at": row.consumed_at, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def update_auth_flow_tokens_for_actor( + self, + *, + actor_id: str, + flow_type: str, + updates: Dict[str, Any], + statuses: Optional[List[str]] = None, + exclude_flow_token_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + normalized_actor_id = str(actor_id or "").strip() + normalized_flow_type = str(flow_type or "").strip() + if not normalized_actor_id or not normalized_flow_type: + return [] + selected_statuses = [str(item).strip() for item in list(statuses or ["active"]) if str(item).strip()] + with self.SessionLocal() as session: + stmt = select(AuthFlowTokenRow).where( + AuthFlowTokenRow.actor_id == normalized_actor_id, + AuthFlowTokenRow.flow_type == normalized_flow_type, + ) + if selected_statuses: + stmt = stmt.where(AuthFlowTokenRow.status.in_(selected_statuses)) + if exclude_flow_token_id: + stmt = stmt.where(AuthFlowTokenRow.flow_token_id != str(exclude_flow_token_id)) + rows = session.execute(stmt).scalars().all() + updated_rows: List[Dict[str, Any]] = [] + for row in rows: + for key in ["status", "payload_json", "expires_at", "consumed_at", "account_id"]: + if key in updates: + value = updates[key] + if key == "payload_json" and value is not None: + value = dict(value) + setattr(row, key, value) + row.updated_at = utcnow_iso() + updated_rows.append( + { + "flow_token_id": row.flow_token_id, + "actor_id": row.actor_id, + "account_id": row.account_id, + "flow_type": row.flow_type, + "token_hash": row.token_hash, + "status": row.status, + "payload_json": dict(row.payload_json or {}), + "expires_at": row.expires_at, + "consumed_at": row.consumed_at, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + ) + session.commit() + return updated_rows + + def revoke_auth_tokens_for_actor(self, actor_id: str, *, reason: str = "security_reset") -> List[Dict[str, Any]]: + now = utcnow_iso() + with self.SessionLocal() as session: + stmt = select(AuthTokenRow).where(AuthTokenRow.actor_id == actor_id, AuthTokenRow.status == "active") + rows = session.execute(stmt).scalars().all() + revoked: List[Dict[str, Any]] = [] + for row in rows: + row.status = "revoked" + row.last_used_at = now + revoked.append( + { + "token_id": row.token_id, + "actor_id": row.actor_id, + "account_id": row.account_id, + "actor_role": row.actor_role, + "status": row.status, + "revoked_reason": reason, + "expires_at": row.expires_at, + "last_used_at": row.last_used_at, + } + ) + session.commit() + return revoked + + def save_auth_delivery_attempt(self, attempt: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "attempt_id": attempt.get("attempt_id") or "attempt_%s" % uuid4().hex[:12], + "actor_id": attempt.get("actor_id"), + "account_id": attempt.get("account_id"), + "flow_type": attempt["flow_type"], + "provider": attempt["provider"], + "email_mode": attempt["email_mode"], + "sender_email": attempt.get("sender_email"), + "recipient_email": attempt["recipient_email"], + "status": attempt["status"], + "provider_message_id": attempt.get("provider_message_id"), + "error_code": attempt.get("error_code"), + "error_reason": attempt.get("error_reason"), + "retryable": "true" if attempt.get("retryable") else "false", + "metadata_json": dict(attempt.get("metadata_json") or {}), + } + with self.SessionLocal() as session: + row = session.get(AuthDeliveryAttemptRow, payload["attempt_id"]) + if row is None: + row = AuthDeliveryAttemptRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.actor_id = payload["actor_id"] + row.account_id = payload["account_id"] + row.flow_type = payload["flow_type"] + row.provider = payload["provider"] + row.email_mode = payload["email_mode"] + row.sender_email = payload["sender_email"] + row.recipient_email = payload["recipient_email"] + row.status = payload["status"] + row.provider_message_id = payload["provider_message_id"] + row.error_code = payload["error_code"] + row.error_reason = payload["error_reason"] + row.retryable = payload["retryable"] + row.metadata_json = payload["metadata_json"] + row.updated_at = now + session.commit() + return { + "attempt_id": payload["attempt_id"], + "actor_id": payload["actor_id"], + "account_id": payload["account_id"], + "flow_type": payload["flow_type"], + "provider": payload["provider"], + "email_mode": payload["email_mode"], + "sender_email": payload["sender_email"], + "recipient_email": payload["recipient_email"], + "status": payload["status"], + "provider_message_id": payload["provider_message_id"], + "error_code": payload["error_code"], + "error_reason": payload["error_reason"], + "retryable": payload["retryable"] == "true", + "metadata_json": payload["metadata_json"], + "created_at": now, + "updated_at": now, + } + + def save_plan(self, plan: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "plan_id": plan["plan_id"], + "display_name": plan["display_name"], + "subscription_tier": plan["subscription_tier"], + "monthly_price_usd": float(plan.get("monthly_price_usd") or 0.0), + "status": plan.get("status", "active"), + "seat_limit": int(plan.get("seat_limit") or 0), + "workspace_limit": int(plan.get("workspace_limit") or 0), + "campaign_limit": int(plan.get("campaign_limit") or 0), + "plan_payload_json": dict(plan.get("plan_payload") or plan.get("plan_payload_json") or {}), + } + with self.SessionLocal() as session: + row = session.get(PlanRow, payload["plan_id"]) + if row is None: + row = PlanRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.display_name = payload["display_name"] + row.subscription_tier = payload["subscription_tier"] + row.monthly_price_usd = payload["monthly_price_usd"] + row.status = payload["status"] + row.seat_limit = payload["seat_limit"] + row.workspace_limit = payload["workspace_limit"] + row.campaign_limit = payload["campaign_limit"] + row.plan_payload_json = payload["plan_payload_json"] + row.updated_at = now + session.commit() + session.refresh(row) + return _plan_payload(row) + + def list_plans(self, *, status: Optional[str] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(PlanRow).order_by(PlanRow.plan_id.asc()) + if status is not None: + stmt = stmt.where(PlanRow.status == status) + rows = session.execute(stmt).scalars() + return [_plan_payload(row) for row in rows] + + def get_plan(self, plan_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(PlanRow, plan_id) + if row is None: + if default is ...: + raise KeyError("unknown_plan:%s" % plan_id) + return default + return _plan_payload(row) + + def save_customer_account(self, customer_account: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "customer_account_id": customer_account.get("customer_account_id") or "cust_%s" % uuid4().hex[:12], + "account_id": customer_account["account_id"], + "display_name": customer_account.get("display_name"), + "status": customer_account.get("status", "trial"), + "plan_id": customer_account["plan_id"], + "seat_limit": int(customer_account.get("seat_limit") or 0), + "workspace_limit": int(customer_account.get("workspace_limit") or 0), + "campaign_limit": int(customer_account.get("campaign_limit") or 0), + "seat_count": int(customer_account.get("seat_count") or 0), + "workspace_count": int(customer_account.get("workspace_count") or 0), + "campaign_count": int(customer_account.get("campaign_count") or 0), + "renewal_due_at": customer_account.get("renewal_due_at"), + "metadata_json": dict(customer_account.get("metadata_json") or customer_account.get("metadata") or {}), + } + with self.SessionLocal() as session: + row = session.get(CustomerAccountRow, payload["customer_account_id"]) + if row is None: + row = CustomerAccountRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.account_id = payload["account_id"] + row.display_name = payload["display_name"] + row.status = payload["status"] + row.plan_id = payload["plan_id"] + row.seat_limit = payload["seat_limit"] + row.workspace_limit = payload["workspace_limit"] + row.campaign_limit = payload["campaign_limit"] + row.seat_count = payload["seat_count"] + row.workspace_count = payload["workspace_count"] + row.campaign_count = payload["campaign_count"] + row.renewal_due_at = payload["renewal_due_at"] + row.metadata_json = payload["metadata_json"] + row.updated_at = now + session.commit() + session.refresh(row) + return _customer_account_payload(row) + + def get_customer_account(self, customer_account_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(CustomerAccountRow, customer_account_id) + if row is None: + if default is ...: + raise KeyError("unknown_customer_account:%s" % customer_account_id) + return default + return _customer_account_payload(row) + + def get_customer_account_by_account_id( + self, + account_id: str, + *, + default: Optional[Dict[str, Any]] = ..., + ) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(CustomerAccountRow).where(CustomerAccountRow.account_id == account_id) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + if default is ...: + raise KeyError("unknown_customer_account_for_account:%s" % account_id) + return default + return _customer_account_payload(row) + + def list_customer_accounts( + self, + *, + status: Optional[str] = None, + plan_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(CustomerAccountRow).order_by(desc(CustomerAccountRow.updated_at)) + if status is not None: + stmt = stmt.where(CustomerAccountRow.status == status) + if plan_id is not None: + stmt = stmt.where(CustomerAccountRow.plan_id == plan_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars() + return [_customer_account_payload(row) for row in rows] + + def save_billing_profile(self, profile: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + payload = { + "billing_profile_id": profile.get("billing_profile_id") or "billing_profile_%s" % uuid4().hex[:12], + "customer_account_id": profile["customer_account_id"], + "account_id": profile["account_id"], + "provider": profile.get("provider", "internal_preview"), + "provider_customer_ref": profile.get("provider_customer_ref"), + "invoice_email": profile.get("invoice_email"), + "legal_name": profile.get("legal_name"), + "billing_country": profile.get("billing_country"), + "tax_status": profile.get("tax_status"), + "status": profile.get("status", "active"), + "profile_payload_json": dict(profile.get("profile_payload_json") or profile.get("profile_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(BillingProfileRow, payload["billing_profile_id"]) + if row is None: + row = BillingProfileRow(created_at=now, updated_at=now, **payload) + session.add(row) + else: + row.customer_account_id = payload["customer_account_id"] + row.account_id = payload["account_id"] + row.provider = payload["provider"] + row.provider_customer_ref = payload["provider_customer_ref"] + row.invoice_email = payload["invoice_email"] + row.legal_name = payload["legal_name"] + row.billing_country = payload["billing_country"] + row.tax_status = payload["tax_status"] + row.status = payload["status"] + row.profile_payload_json = payload["profile_payload_json"] + row.updated_at = now + session.commit() + session.refresh(row) + return _billing_profile_payload(row) + + def list_billing_profiles( + self, + *, + customer_account_id: Optional[str] = None, + account_id: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(BillingProfileRow).order_by(desc(BillingProfileRow.updated_at)) + if customer_account_id is not None: + stmt = stmt.where(BillingProfileRow.customer_account_id == customer_account_id) + if account_id is not None: + stmt = stmt.where(BillingProfileRow.account_id == account_id) + if status is not None: + stmt = stmt.where(BillingProfileRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars() + return [_billing_profile_payload(row) for row in rows] + + def get_billing_profile(self, billing_profile_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(BillingProfileRow, billing_profile_id) + if row is None: + if default is ...: + raise KeyError("unknown_billing_profile:%s" % billing_profile_id) + return default + return _billing_profile_payload(row) + + def save_usage_ledger(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "usage_ledger_id": payload.get("usage_ledger_id") or "usage_ledger_%s" % uuid4().hex[:12], + "account_id": payload["account_id"], + "customer_account_id": payload.get("customer_account_id"), + "plan_id": payload.get("plan_id"), + "status": payload.get("status", "open"), + "billing_period_start": payload["billing_period_start"], + "billing_period_end": payload["billing_period_end"], + "presented_count": int(payload.get("presented_count") or 0), + "handoff_count": int(payload.get("handoff_count") or 0), + "conversion_count": int(payload.get("conversion_count") or 0), + "subtotal_amount_usd": float(payload.get("subtotal_amount_usd") or 0.0), + "disputed_amount_usd": float(payload.get("disputed_amount_usd") or 0.0), + "credited_amount_usd": float(payload.get("credited_amount_usd") or 0.0), + "reversed_amount_usd": float(payload.get("reversed_amount_usd") or 0.0), + "ledger_payload_json": dict(payload.get("ledger_payload_json") or payload.get("ledger_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(UsageLedgerRow, record["usage_ledger_id"]) + if row is None: + row = UsageLedgerRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _usage_ledger_payload(row) + + def list_usage_ledgers( + self, + *, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(UsageLedgerRow).order_by(desc(UsageLedgerRow.updated_at)) + if account_id is not None: + stmt = stmt.where(UsageLedgerRow.account_id == account_id) + if customer_account_id is not None: + stmt = stmt.where(UsageLedgerRow.customer_account_id == customer_account_id) + if status is not None: + stmt = stmt.where(UsageLedgerRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_usage_ledger_payload(row) for row in rows] + + def get_usage_ledger(self, usage_ledger_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(UsageLedgerRow, usage_ledger_id) + if row is None: + if default is ...: + raise KeyError("unknown_usage_ledger:%s" % usage_ledger_id) + return default + return _usage_ledger_payload(row) + + def save_billable_event(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "billable_event_id": payload.get("billable_event_id") or "billable_event_%s" % uuid4().hex[:12], + "usage_ledger_id": payload.get("usage_ledger_id"), + "account_id": payload["account_id"], + "customer_account_id": payload.get("customer_account_id"), + "plan_id": payload.get("plan_id"), + "billable_metric": payload["billable_metric"], + "status": payload.get("status", "recorded"), + "trace_id": payload.get("trace_id"), + "quality_event_id": payload.get("quality_event_id"), + "runtime_receipt_event_id": payload.get("runtime_receipt_event_id"), + "feedback_item_id": payload.get("feedback_item_id"), + "source_surface": payload.get("source_surface"), + "world_version_id": payload.get("world_version_id"), + "session_id": payload.get("session_id"), + "quantity": float(payload.get("quantity") or 0.0), + "unit_price_usd": float(payload.get("unit_price_usd") or 0.0), + "amount_usd": float(payload.get("amount_usd") or 0.0), + "reason_codes_json": list(payload.get("reason_codes_json") or payload.get("reason_codes") or []), + "event_payload_json": dict(payload.get("event_payload_json") or payload.get("event_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(BillableEventRow, record["billable_event_id"]) + if row is None: + row = BillableEventRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _billable_event_payload(row) + + def list_billable_events( + self, + *, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + trace_id: Optional[str] = None, + billable_metric: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(BillableEventRow).order_by(desc(BillableEventRow.created_at)) + if account_id is not None: + stmt = stmt.where(BillableEventRow.account_id == account_id) + if customer_account_id is not None: + stmt = stmt.where(BillableEventRow.customer_account_id == customer_account_id) + if trace_id is not None: + stmt = stmt.where(BillableEventRow.trace_id == trace_id) + if billable_metric is not None: + stmt = stmt.where(BillableEventRow.billable_metric == billable_metric) + if status is not None: + stmt = stmt.where(BillableEventRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_billable_event_payload(row) for row in rows] + + def get_billable_event(self, billable_event_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(BillableEventRow, billable_event_id) + if row is None: + if default is ...: + raise KeyError("unknown_billable_event:%s" % billable_event_id) + return default + return _billable_event_payload(row) + + def update_billable_event_status(self, billable_event_id: str, *, status: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(BillableEventRow, billable_event_id) + if row is None: + raise KeyError("unknown_billable_event:%s" % billable_event_id) + row.status = status + row.updated_at = utcnow_iso() + session.commit() + session.refresh(row) + return _billable_event_payload(row) + + def save_invoice_preview(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "invoice_preview_id": payload.get("invoice_preview_id") or "invoice_preview_%s" % uuid4().hex[:12], + "usage_ledger_id": payload.get("usage_ledger_id"), + "account_id": payload["account_id"], + "customer_account_id": payload.get("customer_account_id"), + "plan_id": payload.get("plan_id"), + "status": payload.get("status", "draft"), + "billing_period_start": payload["billing_period_start"], + "billing_period_end": payload["billing_period_end"], + "subtotal_amount_usd": float(payload.get("subtotal_amount_usd") or 0.0), + "credits_applied_usd": float(payload.get("credits_applied_usd") or 0.0), + "disputed_amount_usd": float(payload.get("disputed_amount_usd") or 0.0), + "credited_amount_usd": float(payload.get("credited_amount_usd") or 0.0), + "reversed_amount_usd": float(payload.get("reversed_amount_usd") or 0.0), + "total_due_usd": float(payload.get("total_due_usd") or 0.0), + "line_items_json": list(payload.get("line_items_json") or payload.get("line_items") or []), + "summary_json": dict(payload.get("summary_json") or payload.get("summary") or {}), + } + with self.SessionLocal() as session: + row = session.get(InvoicePreviewRow, record["invoice_preview_id"]) + if row is None: + row = InvoicePreviewRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _invoice_preview_payload(row) + + def list_invoice_previews( + self, + *, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(InvoicePreviewRow).order_by(desc(InvoicePreviewRow.updated_at)) + if account_id is not None: + stmt = stmt.where(InvoicePreviewRow.account_id == account_id) + if customer_account_id is not None: + stmt = stmt.where(InvoicePreviewRow.customer_account_id == customer_account_id) + if status is not None: + stmt = stmt.where(InvoicePreviewRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_invoice_preview_payload(row) for row in rows] + + def get_invoice_preview(self, invoice_preview_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(InvoicePreviewRow, invoice_preview_id) + if row is None: + if default is ...: + raise KeyError("unknown_invoice_preview:%s" % invoice_preview_id) + return default + return _invoice_preview_payload(row) + + def save_credit_balance(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "credit_balance_id": payload.get("credit_balance_id") or "credit_balance_%s" % uuid4().hex[:12], + "account_id": payload["account_id"], + "customer_account_id": payload.get("customer_account_id"), + "balance_type": payload["balance_type"], + "amount_usd": float(payload.get("amount_usd") or 0.0), + "source_ref_json": dict(payload.get("source_ref_json") or payload.get("source_ref") or {}), + } + with self.SessionLocal() as session: + row = session.get(CreditBalanceRow, record["credit_balance_id"]) + if row is None: + row = CreditBalanceRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _credit_balance_payload(row) + + def list_credit_balances( + self, + *, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + balance_type: Optional[str] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(CreditBalanceRow).order_by(desc(CreditBalanceRow.updated_at)) + if account_id is not None: + stmt = stmt.where(CreditBalanceRow.account_id == account_id) + if customer_account_id is not None: + stmt = stmt.where(CreditBalanceRow.customer_account_id == customer_account_id) + if balance_type is not None: + stmt = stmt.where(CreditBalanceRow.balance_type == balance_type) + rows = session.execute(stmt).scalars().all() + return [_credit_balance_payload(row) for row in rows] + + def save_overage_flag(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "overage_flag_id": payload.get("overage_flag_id") or "overage_flag_%s" % uuid4().hex[:12], + "account_id": payload["account_id"], + "customer_account_id": payload.get("customer_account_id"), + "plan_id": payload.get("plan_id"), + "metric_type": payload["metric_type"], + "status": payload.get("status", "active"), + "observed_units": float(payload.get("observed_units") or 0.0), + "included_units": float(payload.get("included_units") or 0.0), + "overage_units": float(payload.get("overage_units") or 0.0), + "flag_payload_json": dict(payload.get("flag_payload_json") or payload.get("flag_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(OverageFlagRow, record["overage_flag_id"]) + if row is None: + row = OverageFlagRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _overage_flag_payload(row) + + def list_overage_flags( + self, + *, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(OverageFlagRow).order_by(desc(OverageFlagRow.updated_at)) + if account_id is not None: + stmt = stmt.where(OverageFlagRow.account_id == account_id) + if customer_account_id is not None: + stmt = stmt.where(OverageFlagRow.customer_account_id == customer_account_id) + if status is not None: + stmt = stmt.where(OverageFlagRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_overage_flag_payload(row) for row in rows] + + def save_campaign(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "campaign_id": payload.get("campaign_id") or "campaign_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "title": payload["title"], + "target_icp_vertical": payload["target_icp_vertical"], + "cta_text": payload["cta_text"], + "disclosure_text": payload["disclosure_text"], + "activation_status": payload.get("activation_status", "draft"), + "selected_channels_json": list(payload.get("selected_channels_json") or payload.get("selected_channels") or []), + "selected_partner_refs_json": list(payload.get("selected_partner_refs_json") or payload.get("selected_partner_refs") or []), + "primary_review_case_id": payload.get("primary_review_case_id"), + "latest_submission_id": payload.get("latest_submission_id"), + "campaign_payload_json": dict(payload.get("campaign_payload_json") or payload.get("campaign_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(CampaignRow, record["campaign_id"]) + if row is None: + row = CampaignRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _campaign_payload(row) + + def get_campaign(self, campaign_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(CampaignRow, campaign_id) + if row is None: + raise KeyError("unknown_campaign:%s" % campaign_id) + return _campaign_payload(row) + + def list_campaigns( + self, + *, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + activation_status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(CampaignRow).order_by(desc(CampaignRow.updated_at)) + if account_id is not None: + stmt = stmt.where(CampaignRow.account_id == account_id) + if customer_account_id is not None: + stmt = stmt.where(CampaignRow.customer_account_id == customer_account_id) + if activation_status is not None: + stmt = stmt.where(CampaignRow.activation_status == activation_status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_campaign_payload(row) for row in rows] + + def save_campaign_proof_bundle(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "proof_bundle_id": payload.get("proof_bundle_id") or "campaign_proof_%s" % uuid4().hex[:12], + "campaign_id": payload["campaign_id"], + "bundle_label": payload.get("bundle_label", "default"), + "proof_points_json": list(payload.get("proof_points_json") or payload.get("proof_points") or []), + "source_urls_json": list(payload.get("source_urls_json") or payload.get("source_urls") or []), + "artifact_refs_json": list(payload.get("artifact_refs_json") or payload.get("artifact_refs") or []), + "bundle_payload_json": dict(payload.get("bundle_payload_json") or payload.get("bundle_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(CampaignProofBundleRow, record["proof_bundle_id"]) + if row is None: + row = CampaignProofBundleRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _campaign_proof_bundle_payload(row) + + def replace_campaign_proof_bundles(self, *, campaign_id: str, bundles: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + session.execute(delete(CampaignProofBundleRow).where(CampaignProofBundleRow.campaign_id == campaign_id)) + session.commit() + saved: List[Dict[str, Any]] = [] + for bundle in bundles: + saved.append(self.save_campaign_proof_bundle({**bundle, "campaign_id": campaign_id})) + return saved + + def list_campaign_proof_bundles(self, *, campaign_id: str) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(CampaignProofBundleRow).where(CampaignProofBundleRow.campaign_id == campaign_id).order_by(desc(CampaignProofBundleRow.updated_at)) + rows = session.execute(stmt).scalars().all() + return [_campaign_proof_bundle_payload(row) for row in rows] + + def save_campaign_channel_target(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "channel_target_id": payload.get("channel_target_id") or "channel_target_%s" % uuid4().hex[:12], + "campaign_id": payload["campaign_id"], + "channel_name": payload["channel_name"], + "partner_ref": payload.get("partner_ref"), + "priority": int(payload.get("priority") or 0), + "readiness_status": payload.get("readiness_status", "selected"), + "target_payload_json": dict(payload.get("target_payload_json") or payload.get("target_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(CampaignChannelTargetRow, record["channel_target_id"]) + if row is None: + row = CampaignChannelTargetRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _campaign_channel_target_payload(row) + + def replace_campaign_channel_targets(self, *, campaign_id: str, targets: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + session.execute(delete(CampaignChannelTargetRow).where(CampaignChannelTargetRow.campaign_id == campaign_id)) + session.commit() + saved: List[Dict[str, Any]] = [] + for target in targets: + saved.append(self.save_campaign_channel_target({**target, "campaign_id": campaign_id})) + return saved + + def list_campaign_channel_targets(self, *, campaign_id: str) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(CampaignChannelTargetRow).where(CampaignChannelTargetRow.campaign_id == campaign_id).order_by(CampaignChannelTargetRow.priority.asc(), CampaignChannelTargetRow.updated_at.desc()) + rows = session.execute(stmt).scalars().all() + return [_campaign_channel_target_payload(row) for row in rows] + + def save_campaign_review_submission(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "submission_id": payload.get("submission_id") or "campaign_submission_%s" % uuid4().hex[:12], + "campaign_id": payload["campaign_id"], + "review_case_id": payload.get("review_case_id"), + "status": payload.get("status", "submitted"), + "submitted_by": payload["submitted_by"], + "reviewer_id": payload.get("reviewer_id"), + "decision_note": payload.get("decision_note"), + "submitted_at": payload.get("submitted_at") or now, + "decided_at": payload.get("decided_at"), + "submission_payload_json": dict(payload.get("submission_payload_json") or payload.get("submission_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(CampaignReviewSubmissionRow, record["submission_id"]) + if row is None: + row = CampaignReviewSubmissionRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _campaign_review_submission_payload(row) + + def get_campaign_review_submission(self, submission_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(CampaignReviewSubmissionRow, submission_id) + if row is None: + raise KeyError("unknown_campaign_review_submission:%s" % submission_id) + return _campaign_review_submission_payload(row) + + def list_campaign_review_submissions( + self, + *, + campaign_id: Optional[str] = None, + status: Optional[str] = None, + review_case_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(CampaignReviewSubmissionRow).order_by(desc(CampaignReviewSubmissionRow.updated_at)) + if campaign_id is not None: + stmt = stmt.where(CampaignReviewSubmissionRow.campaign_id == campaign_id) + if status is not None: + stmt = stmt.where(CampaignReviewSubmissionRow.status == status) + if review_case_id is not None: + stmt = stmt.where(CampaignReviewSubmissionRow.review_case_id == review_case_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_campaign_review_submission_payload(row) for row in rows] + + def save_partner(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "partner_id": payload.get("partner_id") or "partner_%s" % uuid4().hex[:12], + "name": payload["name"], + "lifecycle_status": payload.get("lifecycle_status", "discovered"), + "sla_status": payload.get("sla_status", "unknown"), + "receipt_capability": payload.get("receipt_capability", "unknown"), + "disclosure_readiness": payload.get("disclosure_readiness", "unknown"), + "billing_readiness": payload.get("billing_readiness", "unknown"), + "allowlisted_channels_json": list(payload.get("allowlisted_channels_json") or payload.get("allowlisted_channels") or []), + "primary_endpoint_url": payload.get("primary_endpoint_url"), + "endpoint_health_status": payload.get("endpoint_health_status", "unknown"), + "partner_payload_json": dict(payload.get("partner_payload_json") or payload.get("partner_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(PartnerRow, record["partner_id"]) + if row is None: + row = PartnerRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _partner_payload(row) + + def get_partner(self, partner_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(PartnerRow, partner_id) + if row is None: + raise KeyError("unknown_partner:%s" % partner_id) + return _partner_payload(row) + + def list_partners( + self, + *, + lifecycle_status: Optional[str] = None, + endpoint_health_status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(PartnerRow).order_by(desc(PartnerRow.updated_at)) + if lifecycle_status is not None: + stmt = stmt.where(PartnerRow.lifecycle_status == lifecycle_status) + if endpoint_health_status is not None: + stmt = stmt.where(PartnerRow.endpoint_health_status == endpoint_health_status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_partner_payload(row) for row in rows] + + def save_partner_capability(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "partner_capability_id": payload.get("partner_capability_id") or "partner_capability_%s" % uuid4().hex[:12], + "partner_id": payload["partner_id"], + "capability_type": payload["capability_type"], + "status": payload.get("status", "unknown"), + "capability_value": payload.get("capability_value"), + "capability_payload_json": dict(payload.get("capability_payload_json") or payload.get("capability_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(PartnerCapabilityRow, record["partner_capability_id"]) + if row is None: + row = PartnerCapabilityRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _partner_capability_payload(row) + + def replace_partner_capabilities(self, *, partner_id: str, capabilities: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + session.execute(delete(PartnerCapabilityRow).where(PartnerCapabilityRow.partner_id == partner_id)) + session.commit() + saved: List[Dict[str, Any]] = [] + for capability in capabilities: + saved.append(self.save_partner_capability({**capability, "partner_id": partner_id})) + return saved + + def list_partner_capabilities(self, *, partner_id: str) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(PartnerCapabilityRow).where(PartnerCapabilityRow.partner_id == partner_id).order_by(desc(PartnerCapabilityRow.updated_at)) + rows = session.execute(stmt).scalars().all() + return [_partner_capability_payload(row) for row in rows] + + def save_partner_health_check(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "health_check_id": payload.get("health_check_id") or "partner_health_%s" % uuid4().hex[:12], + "partner_id": payload["partner_id"], + "endpoint_url": payload.get("endpoint_url"), + "status": payload.get("status", "unknown"), + "status_code": payload.get("status_code"), + "response_time_ms": payload.get("response_time_ms"), + "checked_at": payload.get("checked_at") or now, + "health_payload_json": dict(payload.get("health_payload_json") or payload.get("health_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(PartnerHealthCheckRow, record["health_check_id"]) + if row is None: + row = PartnerHealthCheckRow(created_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _partner_health_check_payload(row) + + def list_partner_health_checks(self, *, partner_id: str, limit: Optional[int] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(PartnerHealthCheckRow).where(PartnerHealthCheckRow.partner_id == partner_id).order_by(desc(PartnerHealthCheckRow.checked_at)) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_partner_health_check_payload(row) for row in rows] + + def save_dispute(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "dispute_id": payload.get("dispute_id") or "dispute_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "campaign_id": payload.get("campaign_id"), + "invoice_preview_id": payload.get("invoice_preview_id"), + "billable_event_id": payload.get("billable_event_id"), + "quality_event_id": payload.get("quality_event_id"), + "trace_id": payload.get("trace_id"), + "dispute_reason_code": payload["dispute_reason_code"], + "note": payload.get("note"), + "status": payload.get("status", "open"), + "requested_amount_usd": float(payload.get("requested_amount_usd") or 0.0), + "resolved_amount_usd": float(payload.get("resolved_amount_usd") or 0.0), + "requested_by": payload["requested_by"], + "reviewer_id": payload.get("reviewer_id"), + "resolution_note": payload.get("resolution_note"), + "dispute_payload_json": dict(payload.get("dispute_payload_json") or payload.get("dispute_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(DisputeRow, record["dispute_id"]) + if row is None: + row = DisputeRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _dispute_payload(row) + + def get_dispute(self, dispute_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(DisputeRow, dispute_id) + if row is None: + raise KeyError("unknown_dispute:%s" % dispute_id) + return _dispute_payload(row) + + def list_disputes( + self, + *, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(DisputeRow).order_by(desc(DisputeRow.updated_at)) + if account_id is not None: + stmt = stmt.where(DisputeRow.account_id == account_id) + if customer_account_id is not None: + stmt = stmt.where(DisputeRow.customer_account_id == customer_account_id) + if status is not None: + stmt = stmt.where(DisputeRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_dispute_payload(row) for row in rows] + + def save_refund_request(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "refund_request_id": payload.get("refund_request_id") or "refund_%s" % uuid4().hex[:12], + "dispute_id": payload.get("dispute_id"), + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "invoice_preview_id": payload.get("invoice_preview_id"), + "billable_event_id": payload.get("billable_event_id"), + "trace_id": payload.get("trace_id"), + "status": payload.get("status", "requested"), + "requested_amount_usd": float(payload.get("requested_amount_usd") or 0.0), + "approved_amount_usd": float(payload.get("approved_amount_usd") or 0.0), + "requested_by": payload["requested_by"], + "reviewer_id": payload.get("reviewer_id"), + "refund_payload_json": dict(payload.get("refund_payload_json") or payload.get("refund_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(RefundRequestRow, record["refund_request_id"]) + if row is None: + row = RefundRequestRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _refund_request_payload(row) + + def list_refund_requests( + self, + *, + account_id: Optional[str] = None, + dispute_id: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(RefundRequestRow).order_by(desc(RefundRequestRow.updated_at)) + if account_id is not None: + stmt = stmt.where(RefundRequestRow.account_id == account_id) + if dispute_id is not None: + stmt = stmt.where(RefundRequestRow.dispute_id == dispute_id) + if status is not None: + stmt = stmt.where(RefundRequestRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_refund_request_payload(row) for row in rows] + + def save_settlement_run(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "settlement_run_id": payload.get("settlement_run_id") or "settlement_run_%s" % uuid4().hex[:12], + "customer_account_id": payload.get("customer_account_id"), + "account_id": payload.get("account_id"), + "billing_period_start": payload.get("billing_period_start"), + "billing_period_end": payload.get("billing_period_end"), + "status": payload.get("status", "draft"), + "subtotal_amount_usd": float(payload.get("subtotal_amount_usd") or 0.0), + "disputed_amount_usd": float(payload.get("disputed_amount_usd") or 0.0), + "credited_amount_usd": float(payload.get("credited_amount_usd") or 0.0), + "reversed_amount_usd": float(payload.get("reversed_amount_usd") or 0.0), + "refunded_amount_usd": float(payload.get("refunded_amount_usd") or 0.0), + "net_amount_usd": float(payload.get("net_amount_usd") or 0.0), + "run_payload_json": dict(payload.get("run_payload_json") or payload.get("run_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(SettlementRunRow, record["settlement_run_id"]) + if row is None: + row = SettlementRunRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _settlement_run_payload(row) + + def list_settlement_runs( + self, + *, + account_id: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(SettlementRunRow).order_by(desc(SettlementRunRow.updated_at)) + if account_id is not None: + stmt = stmt.where(SettlementRunRow.account_id == account_id) + if status is not None: + stmt = stmt.where(SettlementRunRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_settlement_run_payload(row) for row in rows] + + def save_settlement_item(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "settlement_item_id": payload.get("settlement_item_id") or "settlement_item_%s" % uuid4().hex[:12], + "settlement_run_id": payload["settlement_run_id"], + "billable_event_id": payload.get("billable_event_id"), + "invoice_preview_id": payload.get("invoice_preview_id"), + "dispute_id": payload.get("dispute_id"), + "refund_request_id": payload.get("refund_request_id"), + "status": payload.get("status", "approved"), + "amount_usd": float(payload.get("amount_usd") or 0.0), + "item_payload_json": dict(payload.get("item_payload_json") or payload.get("item_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(SettlementItemRow, record["settlement_item_id"]) + if row is None: + row = SettlementItemRow(created_at=utcnow_iso(), **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _settlement_item_payload(row) + + def list_settlement_items(self, *, settlement_run_id: str) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(SettlementItemRow).where(SettlementItemRow.settlement_run_id == settlement_run_id).order_by(desc(SettlementItemRow.created_at)) + rows = session.execute(stmt).scalars().all() + return [_settlement_item_payload(row) for row in rows] + + def save_support_case(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "support_case_id": payload.get("support_case_id") or "support_case_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "campaign_id": payload.get("campaign_id"), + "invoice_preview_id": payload.get("invoice_preview_id"), + "billable_event_id": payload.get("billable_event_id"), + "quality_event_id": payload.get("quality_event_id"), + "trace_id": payload.get("trace_id"), + "case_type": payload.get("case_type", "general"), + "subject": payload["subject"], + "description": payload["description"], + "status": payload.get("status", "open"), + "priority": payload.get("priority", "medium"), + "requested_by": payload["requested_by"], + "owner_id": payload.get("owner_id"), + "resolution_note": payload.get("resolution_note"), + "support_payload_json": dict(payload.get("support_payload_json") or payload.get("support_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(SupportCaseRow, record["support_case_id"]) + if row is None: + row = SupportCaseRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _support_case_payload(row) + + def get_support_case(self, support_case_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(SupportCaseRow, support_case_id) + if row is None: + raise KeyError("unknown_support_case:%s" % support_case_id) + return _support_case_payload(row) + + def list_support_cases( + self, + *, + account_id: Optional[str] = None, + status: Optional[str] = None, + owner_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(SupportCaseRow).order_by(desc(SupportCaseRow.updated_at)) + if account_id is not None: + stmt = stmt.where(SupportCaseRow.account_id == account_id) + if status is not None: + stmt = stmt.where(SupportCaseRow.status == status) + if owner_id is not None: + stmt = stmt.where(SupportCaseRow.owner_id == owner_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_support_case_payload(row) for row in rows] + + def save_manual_adjustment(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "adjustment_id": payload.get("adjustment_id") or "adjustment_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "dispute_id": payload.get("dispute_id"), + "refund_request_id": payload.get("refund_request_id"), + "invoice_preview_id": payload.get("invoice_preview_id"), + "billable_event_id": payload.get("billable_event_id"), + "adjustment_type": payload["adjustment_type"], + "amount_usd": float(payload.get("amount_usd") or 0.0), + "status": payload.get("status", "applied"), + "requested_by": payload["requested_by"], + "reviewer_id": payload.get("reviewer_id"), + "adjustment_payload_json": dict(payload.get("adjustment_payload_json") or payload.get("adjustment_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(ManualAdjustmentRow, record["adjustment_id"]) + if row is None: + row = ManualAdjustmentRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _manual_adjustment_payload(row) + + def list_manual_adjustments( + self, + *, + account_id: Optional[str] = None, + dispute_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ManualAdjustmentRow).order_by(desc(ManualAdjustmentRow.updated_at)) + if account_id is not None: + stmt = stmt.where(ManualAdjustmentRow.account_id == account_id) + if dispute_id is not None: + stmt = stmt.where(ManualAdjustmentRow.dispute_id == dispute_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_manual_adjustment_payload(row) for row in rows] + + def save_audit_log(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "audit_log_id": payload.get("audit_log_id") or "audit_%s" % uuid4().hex[:12], + "actor_id": payload["actor_id"], + "actor_role": payload["actor_role"], + "account_id": payload.get("account_id"), + "customer_account_id": payload.get("customer_account_id"), + "object_type": payload["object_type"], + "object_id": payload["object_id"], + "action_type": payload["action_type"], + "source_surface": payload.get("source_surface", "system"), + "customer_visible_payload_json": dict(payload.get("customer_visible_payload_json") or payload.get("customer_visible_payload") or {}), + "internal_payload_json": dict(payload.get("internal_payload_json") or payload.get("internal_payload") or {}), + "created_at": payload.get("created_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(AuditLogRow, record["audit_log_id"]) + if row is None: + row = AuditLogRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _audit_log_payload(row) + + def list_audit_logs( + self, + *, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + actor_id: Optional[str] = None, + object_type: Optional[str] = None, + object_id: Optional[str] = None, + action_type: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(AuditLogRow).order_by(desc(AuditLogRow.created_at)) + if account_id is not None: + stmt = stmt.where(AuditLogRow.account_id == account_id) + if customer_account_id is not None: + stmt = stmt.where(AuditLogRow.customer_account_id == customer_account_id) + if actor_id is not None: + stmt = stmt.where(AuditLogRow.actor_id == actor_id) + if object_type is not None: + stmt = stmt.where(AuditLogRow.object_type == object_type) + if object_id is not None: + stmt = stmt.where(AuditLogRow.object_id == object_id) + if action_type is not None: + stmt = stmt.where(AuditLogRow.action_type == action_type) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_audit_log_payload(row) for row in rows] + + def save_customer_audit_export(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "audit_export_id": payload.get("audit_export_id") or "audit_export_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "requested_by": payload["requested_by"], + "period_start": payload.get("period_start"), + "period_end": payload.get("period_end"), + "export_payload_json": dict(payload.get("export_payload_json") or payload.get("export_payload") or {}), + "created_at": payload.get("created_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(CustomerAuditExportRow, record["audit_export_id"]) + if row is None: + row = CustomerAuditExportRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _customer_audit_export_payload(row) + + def save_data_retention_policy(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "retention_policy_id": payload.get("retention_policy_id") or "retention_policy_%s" % uuid4().hex[:12], + "scope": payload["scope"], + "retention_days": int(payload.get("retention_days") or 0), + "deletion_mode": payload.get("deletion_mode", "manual_request"), + "status": payload.get("status", "active"), + "policy_payload_json": dict(payload.get("policy_payload_json") or payload.get("policy_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(DataRetentionPolicyRow, record["retention_policy_id"]) + if row is None: + row = DataRetentionPolicyRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _data_retention_policy_payload(row) + + def list_data_retention_policies(self, *, scope: Optional[str] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(DataRetentionPolicyRow).order_by(DataRetentionPolicyRow.scope.asc(), DataRetentionPolicyRow.updated_at.desc()) + if scope is not None: + stmt = stmt.where(DataRetentionPolicyRow.scope == scope) + rows = session.execute(stmt).scalars().all() + return [_data_retention_policy_payload(row) for row in rows] + + def save_data_deletion_request(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "deletion_request_id": payload.get("deletion_request_id") or "deletion_request_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "requested_by": payload["requested_by"], + "scope": payload["scope"], + "status": payload.get("status", "requested"), + "requested_payload_json": dict(payload.get("requested_payload_json") or payload.get("requested_payload") or {}), + "affected_object_counts_json": dict(payload.get("affected_object_counts_json") or payload.get("affected_object_counts") or {}), + "resolution_note": payload.get("resolution_note"), + } + with self.SessionLocal() as session: + row = session.get(DataDeletionRequestRow, record["deletion_request_id"]) + if row is None: + row = DataDeletionRequestRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _data_deletion_request_payload(row) + + def list_data_deletion_requests( + self, + *, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(DataDeletionRequestRow).order_by(desc(DataDeletionRequestRow.updated_at)) + if account_id is not None: + stmt = stmt.where(DataDeletionRequestRow.account_id == account_id) + if customer_account_id is not None: + stmt = stmt.where(DataDeletionRequestRow.customer_account_id == customer_account_id) + if status is not None: + stmt = stmt.where(DataDeletionRequestRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_data_deletion_request_payload(row) for row in rows] + + def save_invoice_issuance(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "invoice_id": payload.get("invoice_id") or "invoice_%s" % uuid4().hex[:12], + "invoice_preview_id": payload["invoice_preview_id"], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "provider": payload["provider"], + "provider_invoice_ref": payload.get("provider_invoice_ref"), + "provider_customer_ref": payload.get("provider_customer_ref"), + "status": payload.get("status", "draft"), + "currency": payload.get("currency", "USD"), + "subtotal_amount_usd": float(payload.get("subtotal_amount_usd") or 0.0), + "total_due_usd": float(payload.get("total_due_usd") or 0.0), + "hosted_invoice_url": payload.get("hosted_invoice_url"), + "invoice_pdf_url": payload.get("invoice_pdf_url"), + "issued_at": payload.get("issued_at"), + "paid_at": payload.get("paid_at"), + "voided_at": payload.get("voided_at"), + "invoice_payload_json": dict(payload.get("invoice_payload_json") or payload.get("invoice_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(InvoiceIssuanceRow, record["invoice_id"]) + if row is None: + row = InvoiceIssuanceRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _invoice_issuance_payload(row) + + def get_invoice_issuance(self, invoice_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(InvoiceIssuanceRow, invoice_id) + if row is None: + if default is ...: + raise KeyError("unknown_invoice:%s" % invoice_id) + return default + return _invoice_issuance_payload(row) + + def get_invoice_issuance_by_provider_ref(self, provider_invoice_ref: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(InvoiceIssuanceRow).where(InvoiceIssuanceRow.provider_invoice_ref == provider_invoice_ref) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + if default is ...: + raise KeyError("unknown_invoice_provider_ref:%s" % provider_invoice_ref) + return default + return _invoice_issuance_payload(row) + + def list_invoice_issuances( + self, + *, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(InvoiceIssuanceRow).order_by(desc(InvoiceIssuanceRow.updated_at)) + if account_id is not None: + stmt = stmt.where(InvoiceIssuanceRow.account_id == account_id) + if customer_account_id is not None: + stmt = stmt.where(InvoiceIssuanceRow.customer_account_id == customer_account_id) + if status is not None: + stmt = stmt.where(InvoiceIssuanceRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_invoice_issuance_payload(row) for row in rows] + + def save_payment_transaction(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "payment_transaction_id": payload.get("payment_transaction_id") or "payment_tx_%s" % uuid4().hex[:12], + "invoice_id": payload.get("invoice_id"), + "customer_account_id": payload.get("customer_account_id"), + "account_id": payload["account_id"], + "provider": payload["provider"], + "provider_transaction_ref": payload.get("provider_transaction_ref"), + "transaction_type": payload.get("transaction_type", "payment"), + "status": payload.get("status", "pending"), + "amount_usd": float(payload.get("amount_usd") or 0.0), + "currency": payload.get("currency", "USD"), + "trace_id": payload.get("trace_id"), + "transaction_payload_json": dict(payload.get("transaction_payload_json") or payload.get("transaction_payload") or {}), + "occurred_at": payload.get("occurred_at") or utcnow_iso(), + "created_at": payload.get("created_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(PaymentTransactionRow, record["payment_transaction_id"]) + if row is None: + row = PaymentTransactionRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _payment_transaction_payload(row) + + def list_payment_transactions( + self, + *, + account_id: Optional[str] = None, + invoice_id: Optional[str] = None, + transaction_type: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(PaymentTransactionRow).order_by(desc(PaymentTransactionRow.occurred_at)) + if account_id is not None: + stmt = stmt.where(PaymentTransactionRow.account_id == account_id) + if invoice_id is not None: + stmt = stmt.where(PaymentTransactionRow.invoice_id == invoice_id) + if transaction_type is not None: + stmt = stmt.where(PaymentTransactionRow.transaction_type == transaction_type) + if status is not None: + stmt = stmt.where(PaymentTransactionRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_payment_transaction_payload(row) for row in rows] + + def save_provider_webhook_event(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "provider_webhook_event_id": payload.get("provider_webhook_event_id") or "provider_webhook_%s" % uuid4().hex[:12], + "provider": payload["provider"], + "provider_event_id": payload["provider_event_id"], + "event_type": payload["event_type"], + "status": payload.get("status", "received"), + "invoice_id": payload.get("invoice_id"), + "account_id": payload.get("account_id"), + "payload_json": dict(payload.get("payload_json") or payload.get("payload") or {}), + "processing_result_json": dict(payload.get("processing_result_json") or payload.get("processing_result") or {}), + "created_at": payload.get("created_at") or utcnow_iso(), + "processed_at": payload.get("processed_at"), + } + with self.SessionLocal() as session: + row = session.get(ProviderWebhookEventRow, record["provider_webhook_event_id"]) + if row is None: + row = ProviderWebhookEventRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _provider_webhook_event_payload(row) + + def get_provider_webhook_event(self, provider_webhook_event_id: str) -> Dict[str, Any]: + with self.SessionLocal() as session: + row = session.get(ProviderWebhookEventRow, provider_webhook_event_id) + if row is None: + raise KeyError("unknown_provider_webhook_event:%s" % provider_webhook_event_id) + return _provider_webhook_event_payload(row) + + def get_provider_webhook_event_by_provider_ref(self, provider: str, provider_event_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ProviderWebhookEventRow).where( + ProviderWebhookEventRow.provider == provider, + ProviderWebhookEventRow.provider_event_id == provider_event_id, + ) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + if default is ...: + raise KeyError("unknown_provider_webhook_event_ref") + return default + return _provider_webhook_event_payload(row) + + def list_provider_webhook_events( + self, + *, + provider: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ProviderWebhookEventRow).order_by(desc(ProviderWebhookEventRow.created_at)) + if provider is not None: + stmt = stmt.where(ProviderWebhookEventRow.provider == provider) + if status is not None: + stmt = stmt.where(ProviderWebhookEventRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_provider_webhook_event_payload(row) for row in rows] + + def save_credit_note(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "credit_note_id": payload.get("credit_note_id") or "credit_note_%s" % uuid4().hex[:12], + "invoice_id": payload["invoice_id"], + "customer_account_id": payload.get("customer_account_id"), + "account_id": payload["account_id"], + "provider": payload["provider"], + "provider_credit_note_ref": payload.get("provider_credit_note_ref"), + "status": payload.get("status", "issued"), + "amount_usd": float(payload.get("amount_usd") or 0.0), + "reason": payload.get("reason"), + "credit_payload_json": dict(payload.get("credit_payload_json") or payload.get("credit_payload") or {}), + "created_at": payload.get("created_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(CreditNoteRow, record["credit_note_id"]) + if row is None: + row = CreditNoteRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _credit_note_payload(row) + + def list_credit_notes(self, *, invoice_id: Optional[str] = None, account_id: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(CreditNoteRow).order_by(desc(CreditNoteRow.created_at)) + if invoice_id is not None: + stmt = stmt.where(CreditNoteRow.invoice_id == invoice_id) + if account_id is not None: + stmt = stmt.where(CreditNoteRow.account_id == account_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_credit_note_payload(row) for row in rows] + + def save_payment_retry_attempt(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "payment_retry_attempt_id": payload.get("payment_retry_attempt_id") or "payment_retry_%s" % uuid4().hex[:12], + "invoice_id": payload.get("invoice_id"), + "customer_account_id": payload.get("customer_account_id"), + "account_id": payload["account_id"], + "provider": payload["provider"], + "status": payload.get("status", "planned"), + "retry_reason": payload.get("retry_reason"), + "attempt_count": int(payload.get("attempt_count") or 1), + "next_retry_at": payload.get("next_retry_at"), + "retry_payload_json": dict(payload.get("retry_payload_json") or payload.get("retry_payload") or {}), + "created_at": payload.get("created_at") or now, + "updated_at": payload.get("updated_at") or now, + } + with self.SessionLocal() as session: + row = session.get(PaymentRetryAttemptRow, record["payment_retry_attempt_id"]) + if row is None: + row = PaymentRetryAttemptRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _payment_retry_attempt_payload(row) + + def list_payment_retry_attempts(self, *, invoice_id: Optional[str] = None, account_id: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(PaymentRetryAttemptRow).order_by(desc(PaymentRetryAttemptRow.updated_at)) + if invoice_id is not None: + stmt = stmt.where(PaymentRetryAttemptRow.invoice_id == invoice_id) + if account_id is not None: + stmt = stmt.where(PaymentRetryAttemptRow.account_id == account_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_payment_retry_attempt_payload(row) for row in rows] + + def save_dunning_event(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "dunning_event_id": payload.get("dunning_event_id") or "dunning_%s" % uuid4().hex[:12], + "invoice_id": payload.get("invoice_id"), + "customer_account_id": payload.get("customer_account_id"), + "account_id": payload["account_id"], + "status": payload.get("status", "scheduled"), + "step": payload["step"], + "event_payload_json": dict(payload.get("event_payload_json") or payload.get("event_payload") or {}), + "created_at": payload.get("created_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(DunningEventRow, record["dunning_event_id"]) + if row is None: + row = DunningEventRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _dunning_event_payload(row) + + def list_dunning_events(self, *, invoice_id: Optional[str] = None, account_id: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(DunningEventRow).order_by(desc(DunningEventRow.created_at)) + if invoice_id is not None: + stmt = stmt.where(DunningEventRow.invoice_id == invoice_id) + if account_id is not None: + stmt = stmt.where(DunningEventRow.account_id == account_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_dunning_event_payload(row) for row in rows] + + def save_renewal_tracker(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "renewal_tracker_id": payload.get("renewal_tracker_id") or "renewal_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "status": payload.get("status", "stable"), + "renewal_due_at": payload.get("renewal_due_at"), + "tracker_payload_json": dict(payload.get("tracker_payload_json") or payload.get("tracker_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(RenewalTrackerRow, record["renewal_tracker_id"]) + if row is None: + row = RenewalTrackerRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _renewal_tracker_payload(row) + + def list_renewal_trackers(self, *, account_id: Optional[str] = None, status: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(RenewalTrackerRow).order_by(desc(RenewalTrackerRow.updated_at)) + if account_id is not None: + stmt = stmt.where(RenewalTrackerRow.account_id == account_id) + if status is not None: + stmt = stmt.where(RenewalTrackerRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_renewal_tracker_payload(row) for row in rows] + + def save_dunning_run(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "dunning_run_id": payload.get("dunning_run_id") or "dunning_run_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "invoice_id": payload.get("invoice_id"), + "status": payload.get("status", "open"), + "current_step": payload.get("current_step", "initial_notice"), + "dunning_payload_json": dict(payload.get("dunning_payload_json") or payload.get("dunning_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(DunningRunRow, record["dunning_run_id"]) + if row is None: + row = DunningRunRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _dunning_run_payload(row) + + def list_dunning_runs(self, *, account_id: Optional[str] = None, status: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(DunningRunRow).order_by(desc(DunningRunRow.updated_at)) + if account_id is not None: + stmt = stmt.where(DunningRunRow.account_id == account_id) + if status is not None: + stmt = stmt.where(DunningRunRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_dunning_run_payload(row) for row in rows] + + def save_pilot_conversion_track(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "pilot_conversion_track_id": payload.get("pilot_conversion_track_id") or "pilot_conversion_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "status": payload.get("status", "watch"), + "track_payload_json": dict(payload.get("track_payload_json") or payload.get("track_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(PilotConversionTrackRow, record["pilot_conversion_track_id"]) + if row is None: + row = PilotConversionTrackRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _pilot_conversion_track_payload(row) + + def list_pilot_conversion_tracks(self, *, account_id: Optional[str] = None, status: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(PilotConversionTrackRow).order_by(desc(PilotConversionTrackRow.updated_at)) + if account_id is not None: + stmt = stmt.where(PilotConversionTrackRow.account_id == account_id) + if status is not None: + stmt = stmt.where(PilotConversionTrackRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_pilot_conversion_track_payload(row) for row in rows] + + def save_expansion_candidate(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "expansion_candidate_id": payload.get("expansion_candidate_id") or "expansion_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "status": payload.get("status", "watch"), + "trigger_type": payload["trigger_type"], + "candidate_payload_json": dict(payload.get("candidate_payload_json") or payload.get("candidate_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(ExpansionCandidateRow, record["expansion_candidate_id"]) + if row is None: + row = ExpansionCandidateRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _expansion_candidate_payload(row) + + def list_expansion_candidates(self, *, account_id: Optional[str] = None, status: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ExpansionCandidateRow).order_by(desc(ExpansionCandidateRow.updated_at)) + if account_id is not None: + stmt = stmt.where(ExpansionCandidateRow.account_id == account_id) + if status is not None: + stmt = stmt.where(ExpansionCandidateRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_expansion_candidate_payload(row) for row in rows] + + def save_churn_risk_flag(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "churn_risk_flag_id": payload.get("churn_risk_flag_id") or "churn_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "status": payload.get("status", "watch"), + "risk_level": payload.get("risk_level", "medium"), + "flag_payload_json": dict(payload.get("flag_payload_json") or payload.get("flag_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(ChurnRiskFlagRow, record["churn_risk_flag_id"]) + if row is None: + row = ChurnRiskFlagRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _churn_risk_flag_payload(row) + + def list_churn_risk_flags(self, *, account_id: Optional[str] = None, status: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ChurnRiskFlagRow).order_by(desc(ChurnRiskFlagRow.updated_at)) + if account_id is not None: + stmt = stmt.where(ChurnRiskFlagRow.account_id == account_id) + if status is not None: + stmt = stmt.where(ChurnRiskFlagRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_churn_risk_flag_payload(row) for row in rows] + + def save_production_signoff(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "signoff_id": payload.get("signoff_id") or "production_signoff_%s" % uuid4().hex[:12], + "launch_label": payload["launch_label"], + "status": payload.get("status", "draft"), + "source_go_live_checklist_id": payload.get("source_go_live_checklist_id"), + "source_manual_signoff_bundle_id": payload.get("source_manual_signoff_bundle_id"), + "rollup_summary_json": dict(payload.get("rollup_summary_json") or payload.get("rollup_summary") or {}), + } + with self.SessionLocal() as session: + row = session.get(ProductionSignoffRow, record["signoff_id"]) + if row is None: + row = ProductionSignoffRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _production_signoff_payload(row) + + def get_production_signoff(self, signoff_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(ProductionSignoffRow, signoff_id) + if row is None: + if default is ...: + raise KeyError("unknown_production_signoff:%s" % signoff_id) + return default + return _production_signoff_payload(row) + + def list_production_signoffs( + self, + *, + status: Optional[str] = None, + launch_label: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ProductionSignoffRow).order_by(desc(ProductionSignoffRow.updated_at)) + if status is not None: + stmt = stmt.where(ProductionSignoffRow.status == status) + if launch_label is not None: + stmt = stmt.where(ProductionSignoffRow.launch_label == launch_label) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_production_signoff_payload(row) for row in rows] + + def save_production_signoff_item(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "signoff_item_id": payload.get("signoff_item_id") or "production_signoff_item_%s" % uuid4().hex[:12], + "signoff_id": payload["signoff_id"], + "item_code": payload["item_code"], + "category": payload["category"], + "label": payload["label"], + "owner_role": payload["owner_role"], + "owner_actor_id": payload.get("owner_actor_id"), + "due_at": payload.get("due_at"), + "status": payload.get("status", "pending"), + "decision_note": payload.get("decision_note"), + "approved_at": payload.get("approved_at"), + "evidence_count": int(payload.get("evidence_count") or 0), + "item_payload_json": dict(payload.get("item_payload_json") or payload.get("item_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(ProductionSignoffItemRow, record["signoff_item_id"]) if row is None: - row = AuthorNotificationRow(created_at=now, updated_at=now, **payload) + row = ProductionSignoffItemRow(created_at=now, updated_at=now, **record) session.add(row) - created_at = now else: - row.world_version_id = payload["world_version_id"] - row.thread_id = payload["thread_id"] - row.approval_id = payload["approval_id"] - row.recipient_id = payload["recipient_id"] - row.recipient_role = payload["recipient_role"] - row.notification_type = payload["notification_type"] - row.status = payload["status"] - row.actor_id = payload["actor_id"] - row.actor_role = payload["actor_role"] - row.title = payload["title"] - row.body = payload["body"] - row.anchor_type = payload["anchor_type"] - row.anchor_key = payload["anchor_key"] - row.metadata_json = payload["metadata_json"] - row.read_at = payload["read_at"] + for key, value in record.items(): + setattr(row, key, value) row.updated_at = now - created_at = row.created_at session.commit() - payload["created_at"] = created_at - payload["updated_at"] = now - return payload + session.refresh(row) + return _production_signoff_item_payload(row) - def save_author_thread_watcher(self, watcher: Dict[str, Any]) -> Dict[str, Any]: - existing = self.list_author_thread_watchers( - thread_id=watcher["thread_id"], - watcher_id=watcher["watcher_id"], - ) - if existing: - return existing[0] - payload = { - "watcher_record_id": watcher.get("watcher_record_id") or "awatcher_%s" % uuid4().hex[:12], - "thread_id": watcher["thread_id"], - "watcher_id": watcher["watcher_id"], - "added_by": watcher["added_by"], + def get_production_signoff_item(self, signoff_item_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(ProductionSignoffItemRow, signoff_item_id) + if row is None: + if default is ...: + raise KeyError("unknown_production_signoff_item:%s" % signoff_item_id) + return default + return _production_signoff_item_payload(row) + + def list_production_signoff_items( + self, + *, + signoff_id: Optional[str] = None, + status: Optional[str] = None, + owner_role: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ProductionSignoffItemRow).order_by(ProductionSignoffItemRow.due_at.asc(), ProductionSignoffItemRow.created_at.asc()) + if signoff_id is not None: + stmt = stmt.where(ProductionSignoffItemRow.signoff_id == signoff_id) + if status is not None: + stmt = stmt.where(ProductionSignoffItemRow.status == status) + if owner_role is not None: + stmt = stmt.where(ProductionSignoffItemRow.owner_role == owner_role) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_production_signoff_item_payload(row) for row in rows] + + def save_production_signoff_evidence(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "evidence_id": payload.get("evidence_id") or "production_signoff_evidence_%s" % uuid4().hex[:12], + "signoff_id": payload["signoff_id"], + "signoff_item_id": payload["signoff_item_id"], + "evidence_type": payload["evidence_type"], + "source_ref_json": dict(payload.get("source_ref_json") or payload.get("source_ref") or {}), + "summary": payload.get("summary"), + "customer_safe": bool(payload.get("customer_safe", False)), + "payload_json": dict(payload.get("payload_json") or payload.get("payload") or {}), + "created_at": payload.get("created_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(ProductionSignoffEvidenceRow, record["evidence_id"]) + if row is None: + row = ProductionSignoffEvidenceRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _production_signoff_evidence_payload(row) + + def list_production_signoff_evidence( + self, + *, + signoff_id: Optional[str] = None, + signoff_item_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ProductionSignoffEvidenceRow).order_by(desc(ProductionSignoffEvidenceRow.created_at)) + if signoff_id is not None: + stmt = stmt.where(ProductionSignoffEvidenceRow.signoff_id == signoff_id) + if signoff_item_id is not None: + stmt = stmt.where(ProductionSignoffEvidenceRow.signoff_item_id == signoff_item_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_production_signoff_evidence_payload(row) for row in rows] + + def save_production_cutover_window(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "cutover_window_id": payload.get("cutover_window_id") or "production_cutover_window_%s" % uuid4().hex[:12], + "signoff_id": payload["signoff_id"], + "launch_wave": payload["launch_wave"], + "target_environment": payload["target_environment"], + "starts_at": payload.get("starts_at"), + "ends_at": payload.get("ends_at"), + "rollback_owner_role": payload.get("rollback_owner_role"), + "status": payload.get("status", "planned"), + "cutover_payload_json": dict(payload.get("cutover_payload_json") or payload.get("cutover_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(ProductionCutoverWindowRow, record["cutover_window_id"]) + if row is None: + row = ProductionCutoverWindowRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _production_cutover_window_payload(row) + + def list_production_cutover_windows( + self, + *, + signoff_id: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ProductionCutoverWindowRow).order_by(ProductionCutoverWindowRow.starts_at.asc(), ProductionCutoverWindowRow.created_at.asc()) + if signoff_id is not None: + stmt = stmt.where(ProductionCutoverWindowRow.signoff_id == signoff_id) + if status is not None: + stmt = stmt.where(ProductionCutoverWindowRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_production_cutover_window_payload(row) for row in rows] + + def save_production_customer_acceptance_record(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "acceptance_record_id": payload.get("acceptance_record_id") or "production_acceptance_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "signoff_id": payload.get("signoff_id"), + "launch_wave": payload["launch_wave"], + "status": payload.get("status", "draft"), + "readiness_summary_json": dict(payload.get("readiness_summary_json") or payload.get("readiness_summary") or {}), + "acceptance_payload_json": dict(payload.get("acceptance_payload_json") or payload.get("acceptance_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(ProductionCustomerAcceptanceRecordRow, record["acceptance_record_id"]) + if row is None: + row = ProductionCustomerAcceptanceRecordRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _production_customer_acceptance_record_payload(row) + + def get_production_customer_acceptance_record(self, acceptance_record_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(ProductionCustomerAcceptanceRecordRow, acceptance_record_id) + if row is None: + if default is ...: + raise KeyError("unknown_production_customer_acceptance_record:%s" % acceptance_record_id) + return default + return _production_customer_acceptance_record_payload(row) + + def list_production_customer_acceptance_records( + self, + *, + account_id: Optional[str] = None, + launch_wave: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ProductionCustomerAcceptanceRecordRow).order_by(desc(ProductionCustomerAcceptanceRecordRow.updated_at)) + if account_id is not None: + stmt = stmt.where(ProductionCustomerAcceptanceRecordRow.account_id == account_id) + if launch_wave is not None: + stmt = stmt.where(ProductionCustomerAcceptanceRecordRow.launch_wave == launch_wave) + if status is not None: + stmt = stmt.where(ProductionCustomerAcceptanceRecordRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_production_customer_acceptance_record_payload(row) for row in rows] + + def save_go_live_ready_account(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "go_live_ready_account_id": payload.get("go_live_ready_account_id") or "go_live_ready_%s" % uuid4().hex[:12], + "customer_account_id": payload["customer_account_id"], + "account_id": payload["account_id"], + "acceptance_record_id": payload["acceptance_record_id"], + "launch_wave": payload["launch_wave"], + "status": payload.get("status", "candidate"), + "readiness_payload_json": dict(payload.get("readiness_payload_json") or payload.get("readiness_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(GoLiveReadyAccountRow, record["go_live_ready_account_id"]) + if row is None: + row = GoLiveReadyAccountRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _go_live_ready_account_payload(row) + + def list_go_live_ready_accounts( + self, + *, + account_id: Optional[str] = None, + launch_wave: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(GoLiveReadyAccountRow).order_by(desc(GoLiveReadyAccountRow.updated_at)) + if account_id is not None: + stmt = stmt.where(GoLiveReadyAccountRow.account_id == account_id) + if launch_wave is not None: + stmt = stmt.where(GoLiveReadyAccountRow.launch_wave == launch_wave) + if status is not None: + stmt = stmt.where(GoLiveReadyAccountRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_go_live_ready_account_payload(row) for row in rows] + + def save_launch_wave_status(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "launch_wave_status_id": payload.get("launch_wave_status_id") or "launch_wave_%s" % uuid4().hex[:12], + "launch_wave": payload["launch_wave"], + "status": payload.get("status", "planned"), + "target_environment": payload.get("target_environment", "production"), + "wave_payload_json": dict(payload.get("wave_payload_json") or payload.get("wave_payload") or {}), } + with self.SessionLocal() as session: + row = session.get(LaunchWaveStatusRow, record["launch_wave_status_id"]) + if row is None: + row = LaunchWaveStatusRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _launch_wave_status_payload(row) + + def list_launch_wave_statuses( + self, + *, + launch_wave: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(LaunchWaveStatusRow).order_by(desc(LaunchWaveStatusRow.updated_at)) + if launch_wave is not None: + stmt = stmt.where(LaunchWaveStatusRow.launch_wave == launch_wave) + if status is not None: + stmt = stmt.where(LaunchWaveStatusRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_launch_wave_status_payload(row) for row in rows] + + def save_production_preflight_run(self, payload: Dict[str, Any]) -> Dict[str, Any]: now = utcnow_iso() + record = { + "preflight_run_id": payload.get("preflight_run_id") or "production_preflight_run_%s" % uuid4().hex[:12], + "signoff_id": payload.get("signoff_id"), + "launch_wave": payload["launch_wave"], + "target_environment": payload.get("target_environment", "production"), + "status": payload.get("status", "running"), + "go_no_go": payload.get("go_no_go", "manual_review"), + "hard_fail_count": int(payload.get("hard_fail_count") or 0), + "soft_fail_count": int(payload.get("soft_fail_count") or 0), + "run_payload_json": dict(payload.get("run_payload_json") or payload.get("run_payload") or {}), + } + with self.SessionLocal() as session: + row = session.get(ProductionPreflightRunRow, record["preflight_run_id"]) + if row is None: + row = ProductionPreflightRunRow(created_at=now, updated_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + row.updated_at = now + session.commit() + session.refresh(row) + return _production_preflight_run_payload(row) + + def get_production_preflight_run(self, preflight_run_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(ProductionPreflightRunRow, preflight_run_id) + if row is None: + if default is ...: + raise KeyError("unknown_production_preflight_run:%s" % preflight_run_id) + return default + return _production_preflight_run_payload(row) + + def list_production_preflight_runs( + self, + *, + signoff_id: Optional[str] = None, + launch_wave: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ProductionPreflightRunRow).order_by(desc(ProductionPreflightRunRow.updated_at)) + if signoff_id is not None: + stmt = stmt.where(ProductionPreflightRunRow.signoff_id == signoff_id) + if launch_wave is not None: + stmt = stmt.where(ProductionPreflightRunRow.launch_wave == launch_wave) + if status is not None: + stmt = stmt.where(ProductionPreflightRunRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_production_preflight_run_payload(row) for row in rows] + + def save_production_preflight_check(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "preflight_check_id": payload.get("preflight_check_id") or "production_preflight_check_%s" % uuid4().hex[:12], + "preflight_run_id": payload["preflight_run_id"], + "check_key": payload["check_key"], + "linked_signoff_item_code": payload.get("linked_signoff_item_code"), + "owner_role": payload["owner_role"], + "status": payload.get("status", "passed"), + "summary": payload.get("summary"), + "evidence_ref": payload.get("evidence_ref"), + "payload_json": dict(payload.get("payload_json") or payload.get("payload") or {}), + "created_at": payload.get("created_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(ProductionPreflightCheckRow, record["preflight_check_id"]) + if row is None: + row = ProductionPreflightCheckRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _production_preflight_check_payload(row) + + def list_production_preflight_checks( + self, + *, + preflight_run_id: Optional[str] = None, + linked_signoff_item_code: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(ProductionPreflightCheckRow).order_by(ProductionPreflightCheckRow.created_at.asc()) + if preflight_run_id is not None: + stmt = stmt.where(ProductionPreflightCheckRow.preflight_run_id == preflight_run_id) + if linked_signoff_item_code is not None: + stmt = stmt.where(ProductionPreflightCheckRow.linked_signoff_item_code == linked_signoff_item_code) + if status is not None: + stmt = stmt.where(ProductionPreflightCheckRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_production_preflight_check_payload(row) for row in rows] + + def save_first_7_day_outcome(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "first_7_day_outcome_id": payload.get("first_7_day_outcome_id") or "first_7_day_outcome_%s" % uuid4().hex[:12], + "account_id": payload["account_id"], + "customer_account_id": payload.get("customer_account_id"), + "launch_wave": payload["launch_wave"], + "launch_anchor_at": payload.get("launch_anchor_at"), + "outcome_payload_json": dict(payload.get("outcome_payload_json") or payload.get("outcome_payload") or {}), + "generated_at": payload.get("generated_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(First7DayOutcomeRow, record["first_7_day_outcome_id"]) + if row is None: + row = First7DayOutcomeRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _first_7_day_outcome_payload(row) + + def list_first_7_day_outcomes( + self, + *, + account_id: Optional[str] = None, + launch_wave: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(First7DayOutcomeRow).order_by(desc(First7DayOutcomeRow.generated_at)) + if account_id is not None: + stmt = stmt.where(First7DayOutcomeRow.account_id == account_id) + if launch_wave is not None: + stmt = stmt.where(First7DayOutcomeRow.launch_wave == launch_wave) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_first_7_day_outcome_payload(row) for row in rows] + + def save_first_30_day_value_summary(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "first_30_day_value_summary_id": payload.get("first_30_day_value_summary_id") or "first_30_day_value_summary_%s" % uuid4().hex[:12], + "account_id": payload["account_id"], + "customer_account_id": payload.get("customer_account_id"), + "launch_wave": payload["launch_wave"], + "launch_anchor_at": payload.get("launch_anchor_at"), + "provisional": bool(payload.get("provisional", True)), + "summary_payload_json": dict(payload.get("summary_payload_json") or payload.get("summary_payload") or {}), + "generated_at": payload.get("generated_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(First30DayValueSummaryRow, record["first_30_day_value_summary_id"]) + if row is None: + row = First30DayValueSummaryRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _first_30_day_value_summary_payload(row) + + def list_first_30_day_value_summaries( + self, + *, + account_id: Optional[str] = None, + launch_wave: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(First30DayValueSummaryRow).order_by(desc(First30DayValueSummaryRow.generated_at)) + if account_id is not None: + stmt = stmt.where(First30DayValueSummaryRow.account_id == account_id) + if launch_wave is not None: + stmt = stmt.where(First30DayValueSummaryRow.launch_wave == launch_wave) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_first_30_day_value_summary_payload(row) for row in rows] + + def save_pilot_to_paid_readiness_score(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "pilot_to_paid_readiness_score_id": payload.get("pilot_to_paid_readiness_score_id") or "pilot_to_paid_readiness_score_%s" % uuid4().hex[:12], + "account_id": payload["account_id"], + "customer_account_id": payload.get("customer_account_id"), + "launch_wave": payload["launch_wave"], + "launch_anchor_at": payload.get("launch_anchor_at"), + "score": float(payload.get("score") or 0.0), + "band": payload.get("band", "watch"), + "score_payload_json": dict(payload.get("score_payload_json") or payload.get("score_payload") or {}), + "generated_at": payload.get("generated_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(PilotToPaidReadinessScoreRow, record["pilot_to_paid_readiness_score_id"]) + if row is None: + row = PilotToPaidReadinessScoreRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _pilot_to_paid_readiness_score_payload(row) + + def list_pilot_to_paid_readiness_scores( + self, + *, + account_id: Optional[str] = None, + launch_wave: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - session.add(AuthorThreadWatcherRow(created_at=now, **payload)) - session.commit() - payload["created_at"] = now - return payload + stmt = select(PilotToPaidReadinessScoreRow).order_by(desc(PilotToPaidReadinessScoreRow.generated_at)) + if account_id is not None: + stmt = stmt.where(PilotToPaidReadinessScoreRow.account_id == account_id) + if launch_wave is not None: + stmt = stmt.where(PilotToPaidReadinessScoreRow.launch_wave == launch_wave) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_pilot_to_paid_readiness_score_payload(row) for row in rows] - def save_author_draft_watcher(self, watcher: Dict[str, Any]) -> Dict[str, Any]: - existing = self.list_author_draft_watchers( - world_version_id=watcher["world_version_id"], - watcher_id=watcher["watcher_id"], - ) - if existing: - return existing[0] - payload = { - "watcher_record_id": watcher.get("watcher_record_id") or "adwatcher_%s" % uuid4().hex[:12], - "world_version_id": watcher["world_version_id"], - "watcher_id": watcher["watcher_id"], - "added_by": watcher["added_by"], + def save_customer_success_snapshot(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "customer_success_snapshot_id": payload.get("customer_success_snapshot_id") or "customer_success_snapshot_%s" % uuid4().hex[:12], + "account_id": payload["account_id"], + "customer_account_id": payload.get("customer_account_id"), + "launch_wave": payload["launch_wave"], + "launch_anchor_at": payload.get("launch_anchor_at"), + "snapshot_payload_json": dict(payload.get("snapshot_payload_json") or payload.get("snapshot_payload") or {}), + "generated_at": payload.get("generated_at") or utcnow_iso(), } - now = utcnow_iso() with self.SessionLocal() as session: - session.add(AuthorDraftWatcherRow(created_at=now, **payload)) + row = session.get(CustomerSuccessSnapshotRow, record["customer_success_snapshot_id"]) + if row is None: + row = CustomerSuccessSnapshotRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) session.commit() - payload["created_at"] = now - return payload + session.refresh(row) + return _customer_success_snapshot_payload(row) - def list_author_thread_watchers( + def list_customer_success_snapshots( self, *, - thread_id: Optional[str] = None, - watcher_id: Optional[str] = None, + account_id: Optional[str] = None, + launch_wave: Optional[str] = None, + limit: Optional[int] = None, ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - stmt = select(AuthorThreadWatcherRow).order_by(AuthorThreadWatcherRow.created_at.asc()) - if thread_id is not None: - stmt = stmt.where(AuthorThreadWatcherRow.thread_id == thread_id) - if watcher_id is not None: - stmt = stmt.where(AuthorThreadWatcherRow.watcher_id == watcher_id) - rows = session.execute(stmt).scalars() - return [ - { - "watcher_record_id": row.watcher_record_id, - "thread_id": row.thread_id, - "watcher_id": row.watcher_id, - "added_by": row.added_by, - "created_at": row.created_at, - } - for row in rows - ] + stmt = select(CustomerSuccessSnapshotRow).order_by(desc(CustomerSuccessSnapshotRow.generated_at)) + if account_id is not None: + stmt = stmt.where(CustomerSuccessSnapshotRow.account_id == account_id) + if launch_wave is not None: + stmt = stmt.where(CustomerSuccessSnapshotRow.launch_wave == launch_wave) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_customer_success_snapshot_payload(row) for row in rows] - def delete_author_thread_watcher(self, *, thread_id: str, watcher_id: str) -> Dict[str, Any]: - removed = {"thread_id": thread_id, "watcher_id": watcher_id, "deleted": False} + def save_library_stats_cube(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "library_stats_cube_id": payload.get("library_stats_cube_id") or "library_stats_cube_%s" % uuid4().hex[:12], + "account_id": payload["account_id"], + "semantic_version": str(payload.get("semantic_version") or "library_stats_semantic/v2"), + "snapshot_payload_json": dict(payload.get("snapshot_payload_json") or payload.get("snapshot_payload") or {}), + "source_breakdown_json": dict(payload.get("source_breakdown_json") or payload.get("source_breakdown") or {}), + "source_updated_at": payload.get("source_updated_at") or now, + "invalidated_at": payload.get("invalidated_at"), + "last_invalidated_event_name": payload.get("last_invalidated_event_name"), + "last_invalidated_event_at": payload.get("last_invalidated_event_at"), + "created_at": payload.get("created_at") or now, + "updated_at": now, + } with self.SessionLocal() as session: - rows = session.execute( - select(AuthorThreadWatcherRow).where( - AuthorThreadWatcherRow.thread_id == thread_id, - AuthorThreadWatcherRow.watcher_id == watcher_id, - ) - ).scalars().all() - for row in rows: - removed["deleted"] = True - removed["watcher_record_id"] = row.watcher_record_id - removed["created_at"] = row.created_at - session.delete(row) + stmt = select(LibraryStatsCubeRow).where(LibraryStatsCubeRow.account_id == record["account_id"]) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + row = LibraryStatsCubeRow(**record) + session.add(row) + else: + row.semantic_version = record["semantic_version"] + row.snapshot_payload_json = record["snapshot_payload_json"] + row.source_breakdown_json = record["source_breakdown_json"] + row.source_updated_at = record["source_updated_at"] + row.invalidated_at = record["invalidated_at"] + row.last_invalidated_event_name = record["last_invalidated_event_name"] + row.last_invalidated_event_at = record["last_invalidated_event_at"] + row.updated_at = record["updated_at"] + record["library_stats_cube_id"] = row.library_stats_cube_id session.commit() - return removed + session.refresh(row) + return _library_stats_cube_payload(row) - def list_author_draft_watchers( + def invalidate_library_stats_cube( self, *, - world_version_id: Optional[str] = None, - watcher_id: Optional[str] = None, + account_id: str, + event_name: str, + occurred_at: Optional[str] = None, + ) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "library_stats_cube_id": "library_stats_cube_%s" % uuid4().hex[:12], + "account_id": account_id, + "semantic_version": "library_stats_semantic/v2", + "snapshot_payload_json": {}, + "source_breakdown_json": {}, + "source_updated_at": occurred_at or now, + "invalidated_at": now, + "last_invalidated_event_name": str(event_name or "").strip() or None, + "last_invalidated_event_at": occurred_at or now, + "created_at": now, + "updated_at": now, + } + with self.SessionLocal() as session: + stmt = select(LibraryStatsCubeRow).where(LibraryStatsCubeRow.account_id == account_id) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + row = LibraryStatsCubeRow(**record) + session.add(row) + else: + row.invalidated_at = record["invalidated_at"] + row.last_invalidated_event_name = record["last_invalidated_event_name"] + row.last_invalidated_event_at = record["last_invalidated_event_at"] + row.updated_at = record["updated_at"] + record["library_stats_cube_id"] = row.library_stats_cube_id + session.commit() + session.refresh(row) + return _library_stats_cube_payload(row) + + def get_library_stats_cube(self, account_id: str, default: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + with self.SessionLocal() as session: + stmt = select(LibraryStatsCubeRow).where(LibraryStatsCubeRow.account_id == account_id) + row = session.execute(stmt).scalar_one_or_none() + if row is None: + if default is not None: + return default + raise KeyError("unknown_library_stats_cube:%s" % account_id) + return _library_stats_cube_payload(row) + + def list_library_stats_cubes( + self, + *, + account_id: Optional[str] = None, + limit: Optional[int] = None, ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - stmt = select(AuthorDraftWatcherRow).order_by(AuthorDraftWatcherRow.created_at.asc()) - if world_version_id is not None: - stmt = stmt.where(AuthorDraftWatcherRow.world_version_id == world_version_id) - if watcher_id is not None: - stmt = stmt.where(AuthorDraftWatcherRow.watcher_id == watcher_id) - rows = session.execute(stmt).scalars() - return [ - { - "watcher_record_id": row.watcher_record_id, - "world_version_id": row.world_version_id, - "watcher_id": row.watcher_id, - "added_by": row.added_by, - "created_at": row.created_at, - } - for row in rows - ] + stmt = select(LibraryStatsCubeRow).order_by(desc(LibraryStatsCubeRow.updated_at)) + if account_id is not None: + stmt = stmt.where(LibraryStatsCubeRow.account_id == account_id) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_library_stats_cube_payload(row) for row in rows] - def delete_author_draft_watcher(self, *, world_version_id: str, watcher_id: str) -> Dict[str, Any]: - removed = {"world_version_id": world_version_id, "watcher_id": watcher_id, "deleted": False} + def save_production_launch_event(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "launch_event_id": payload.get("launch_event_id") or "production_launch_event_%s" % uuid4().hex[:12], + "launch_wave": payload["launch_wave"], + "account_id": payload.get("account_id"), + "event_category": payload["event_category"], + "event_type": payload["event_type"], + "phase": payload["phase"], + "severity": payload.get("severity", "info"), + "related_object_type": payload.get("related_object_type"), + "related_object_id": payload.get("related_object_id"), + "occurred_at": payload.get("occurred_at") or now, + "event_payload_json": dict(payload.get("event_payload_json") or payload.get("event_payload") or {}), + } with self.SessionLocal() as session: - rows = session.execute( - select(AuthorDraftWatcherRow).where( - AuthorDraftWatcherRow.world_version_id == world_version_id, - AuthorDraftWatcherRow.watcher_id == watcher_id, - ) - ).scalars().all() - for row in rows: - removed["deleted"] = True - removed["watcher_record_id"] = row.watcher_record_id - removed["created_at"] = row.created_at - session.delete(row) + row = session.get(ProductionLaunchEventRow, record["launch_event_id"]) + if row is None: + row = ProductionLaunchEventRow(created_at=now, **record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) session.commit() - return removed + session.refresh(row) + return _production_launch_event_payload(row) - def get_author_notification(self, notification_id: str) -> Dict[str, Any]: + def list_production_launch_events( + self, + *, + launch_wave: Optional[str] = None, + account_id: Optional[str] = None, + phase: Optional[str] = None, + severity: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - row = session.get(AuthorNotificationRow, notification_id) + stmt = select(ProductionLaunchEventRow).order_by(ProductionLaunchEventRow.occurred_at.asc(), ProductionLaunchEventRow.created_at.asc()) + if launch_wave is not None: + stmt = stmt.where(ProductionLaunchEventRow.launch_wave == launch_wave) + if account_id is not None: + stmt = stmt.where(ProductionLaunchEventRow.account_id == account_id) + if phase is not None: + stmt = stmt.where(ProductionLaunchEventRow.phase == phase) + if severity is not None: + stmt = stmt.where(ProductionLaunchEventRow.severity == severity) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_production_launch_event_payload(row) for row in rows] + + def save_production_postmortem_record(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "postmortem_record_id": payload.get("postmortem_record_id") or "production_postmortem_record_%s" % uuid4().hex[:12], + "launch_wave": payload["launch_wave"], + "account_id": payload.get("account_id"), + "status": payload.get("status", "draft"), + "summary_json": dict(payload.get("summary_json") or payload.get("summary") or {}), + "generated_at": payload.get("generated_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(ProductionPostmortemRecordRow, record["postmortem_record_id"]) if row is None: - raise KeyError("unknown_author_notification:%s" % notification_id) - return { - "notification_id": row.notification_id, - "world_version_id": row.world_version_id, - "thread_id": row.thread_id, - "approval_id": row.approval_id, - "recipient_id": row.recipient_id, - "recipient_role": row.recipient_role, - "notification_type": row.notification_type, - "status": row.status, - "actor_id": row.actor_id, - "actor_role": row.actor_role, - "title": row.title, - "body": row.body, - "anchor_type": row.anchor_type, - "anchor_key": row.anchor_key, - "metadata_json": dict(row.metadata_json or {}), - "read_at": row.read_at, - "created_at": row.created_at, - "updated_at": row.updated_at, - } + row = ProductionPostmortemRecordRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _production_postmortem_record_payload(row) - def list_author_notifications( + def list_production_postmortem_records( self, *, - recipient_id: Optional[str] = None, - world_version_id: Optional[str] = None, - thread_id: Optional[str] = None, - approval_id: Optional[str] = None, + launch_wave: Optional[str] = None, + account_id: Optional[str] = None, status: Optional[str] = None, - notification_type: Optional[str] = None, - cursor_updated_at: Optional[str] = None, - cursor_notification_id: Optional[str] = None, limit: Optional[int] = None, ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - stmt = select(AuthorNotificationRow).order_by(desc(AuthorNotificationRow.updated_at), desc(AuthorNotificationRow.notification_id)) - if recipient_id is not None: - stmt = stmt.where(AuthorNotificationRow.recipient_id == recipient_id) - if world_version_id is not None: - stmt = stmt.where(AuthorNotificationRow.world_version_id == world_version_id) - if thread_id is not None: - stmt = stmt.where(AuthorNotificationRow.thread_id == thread_id) - if approval_id is not None: - stmt = stmt.where(AuthorNotificationRow.approval_id == approval_id) + stmt = select(ProductionPostmortemRecordRow).order_by(desc(ProductionPostmortemRecordRow.generated_at)) + if launch_wave is not None: + stmt = stmt.where(ProductionPostmortemRecordRow.launch_wave == launch_wave) + if account_id is not None: + stmt = stmt.where(ProductionPostmortemRecordRow.account_id == account_id) if status is not None: - stmt = stmt.where(AuthorNotificationRow.status == status) - if notification_type is not None: - stmt = stmt.where(AuthorNotificationRow.notification_type == notification_type) - rows = session.execute(stmt).scalars() - items = [ - { - "notification_id": row.notification_id, - "world_version_id": row.world_version_id, - "thread_id": row.thread_id, - "approval_id": row.approval_id, - "recipient_id": row.recipient_id, - "recipient_role": row.recipient_role, - "notification_type": row.notification_type, - "status": row.status, - "actor_id": row.actor_id, - "actor_role": row.actor_role, - "title": row.title, - "body": row.body, - "anchor_type": row.anchor_type, - "anchor_key": row.anchor_key, - "metadata_json": dict(row.metadata_json or {}), - "read_at": row.read_at, - "created_at": row.created_at, - "updated_at": row.updated_at, - } - for row in rows - ] - if cursor_updated_at is not None and cursor_notification_id is not None: - filtered = [] - for item in items: - updated_at = str(item.get("updated_at") or "") - notification_id_value = str(item.get("notification_id") or "") - if updated_at < cursor_updated_at: - filtered.append(item) - elif updated_at == cursor_updated_at and notification_id_value < cursor_notification_id: - filtered.append(item) - items = filtered - if limit is not None: - items = items[:limit] - return items - - def save_author_notification_preference(self, preference: Dict[str, Any]) -> Dict[str, Any]: - now = utcnow_iso() - payload = { - "preference_id": preference.get("preference_id") or "apref_%s" % uuid4().hex[:12], - "actor_id": preference["actor_id"], - "notification_type": preference["notification_type"], - "in_app_enabled": "true" if preference.get("in_app_enabled", True) else "false", - "async_mirror_enabled": "true" if preference.get("async_mirror_enabled", True) else "false", - "async_sink_name": preference.get("async_sink_name"), - "delivery_target": preference.get("delivery_target"), + stmt = stmt.where(ProductionPostmortemRecordRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_production_postmortem_record_payload(row) for row in rows] + + def save_go_live_day_run(self, payload: Dict[str, Any]) -> Dict[str, Any]: + now = utcnow_iso() + record = { + "go_live_day_run_id": payload.get("go_live_day_run_id") or "go_live_day_run_%s" % uuid4().hex[:12], + "signoff_id": payload.get("signoff_id"), + "launch_wave": payload["launch_wave"], + "account_id": payload.get("account_id"), + "status": payload.get("status", "running"), + "activation_state_before": payload.get("activation_state_before"), + "activation_state_after": payload.get("activation_state_after"), + "report_payload_json": dict(payload.get("report_payload_json") or payload.get("report_payload") or {}), } with self.SessionLocal() as session: - stmt = select(AuthorNotificationPreferenceRow).where( - AuthorNotificationPreferenceRow.actor_id == payload["actor_id"], - AuthorNotificationPreferenceRow.notification_type == payload["notification_type"], - ) - row = session.execute(stmt).scalar_one_or_none() + row = session.get(GoLiveDayRunRow, record["go_live_day_run_id"]) if row is None: - row = AuthorNotificationPreferenceRow(updated_at=now, **payload) + row = GoLiveDayRunRow(created_at=now, updated_at=now, **record) session.add(row) else: - row.in_app_enabled = payload["in_app_enabled"] - row.async_mirror_enabled = payload["async_mirror_enabled"] - row.async_sink_name = payload["async_sink_name"] - row.delivery_target = payload["delivery_target"] + for key, value in record.items(): + setattr(row, key, value) row.updated_at = now - payload["preference_id"] = row.preference_id session.commit() - return { - **payload, - "in_app_enabled": payload["in_app_enabled"] == "true", - "async_mirror_enabled": payload["async_mirror_enabled"] == "true", - "updated_at": now, - } + session.refresh(row) + return _go_live_day_run_payload(row) - def list_author_notification_preferences( + def get_go_live_day_run(self, go_live_day_run_id: str, *, default: Optional[Dict[str, Any]] = ...) -> Optional[Dict[str, Any]]: + with self.SessionLocal() as session: + row = session.get(GoLiveDayRunRow, go_live_day_run_id) + if row is None: + if default is ...: + raise KeyError("unknown_go_live_day_run:%s" % go_live_day_run_id) + return default + return _go_live_day_run_payload(row) + + def list_go_live_day_runs( self, *, - actor_id: Optional[str] = None, - notification_type: Optional[str] = None, + launch_wave: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - stmt = select(AuthorNotificationPreferenceRow).order_by( - AuthorNotificationPreferenceRow.actor_id.asc(), - AuthorNotificationPreferenceRow.notification_type.asc(), - ) - if actor_id is not None: - stmt = stmt.where(AuthorNotificationPreferenceRow.actor_id == actor_id) - if notification_type is not None: - stmt = stmt.where(AuthorNotificationPreferenceRow.notification_type == notification_type) - rows = session.execute(stmt).scalars() - return [ - { - "preference_id": row.preference_id, - "actor_id": row.actor_id, - "notification_type": row.notification_type, - "in_app_enabled": row.in_app_enabled == "true", - "async_mirror_enabled": row.async_mirror_enabled == "true", - "async_sink_name": row.async_sink_name, - "delivery_target": row.delivery_target, - "updated_at": row.updated_at, - } - for row in rows - ] + stmt = select(GoLiveDayRunRow).order_by(desc(GoLiveDayRunRow.updated_at)) + if launch_wave is not None: + stmt = stmt.where(GoLiveDayRunRow.launch_wave == launch_wave) + if status is not None: + stmt = stmt.where(GoLiveDayRunRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_go_live_day_run_payload(row) for row in rows] - def save_auth_identity(self, identity: Dict[str, Any]) -> Dict[str, Any]: - now = utcnow_iso() - payload = { - "actor_id": identity["actor_id"], - "account_id": identity.get("account_id"), - "actor_role": identity["actor_role"], - "display_name": identity.get("display_name"), - "password_hash": identity["password_hash"], - "password_salt": identity["password_salt"], - "status": identity.get("status", "active"), + def save_go_live_day_checkpoint(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "go_live_day_checkpoint_id": payload.get("go_live_day_checkpoint_id") or "go_live_day_checkpoint_%s" % uuid4().hex[:12], + "go_live_day_run_id": payload["go_live_day_run_id"], + "checkpoint_key": payload["checkpoint_key"], + "status": payload.get("status", "passed"), + "summary": payload.get("summary"), + "evidence_ref": payload.get("evidence_ref"), + "rollback_recommendation": payload.get("rollback_recommendation"), + "checkpoint_payload_json": dict(payload.get("checkpoint_payload_json") or payload.get("checkpoint_payload") or {}), + "created_at": payload.get("created_at") or utcnow_iso(), } with self.SessionLocal() as session: - row = session.get(AuthIdentityRow, payload["actor_id"]) + row = session.get(GoLiveDayCheckpointRow, record["go_live_day_checkpoint_id"]) if row is None: - row = AuthIdentityRow(created_at=now, updated_at=now, **payload) + row = GoLiveDayCheckpointRow(**record) session.add(row) else: - row.account_id = payload["account_id"] - row.actor_role = payload["actor_role"] - row.display_name = payload["display_name"] - row.password_hash = payload["password_hash"] - row.password_salt = payload["password_salt"] - row.status = payload["status"] - row.updated_at = now + for key, value in record.items(): + setattr(row, key, value) session.commit() - return { - **payload, - "created_at": now, - "updated_at": now, + session.refresh(row) + return _go_live_day_checkpoint_payload(row) + + def list_go_live_day_checkpoints( + self, + *, + go_live_day_run_id: Optional[str] = None, + checkpoint_key: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(GoLiveDayCheckpointRow).order_by(GoLiveDayCheckpointRow.created_at.asc()) + if go_live_day_run_id is not None: + stmt = stmt.where(GoLiveDayCheckpointRow.go_live_day_run_id == go_live_day_run_id) + if checkpoint_key is not None: + stmt = stmt.where(GoLiveDayCheckpointRow.checkpoint_key == checkpoint_key) + if status is not None: + stmt = stmt.where(GoLiveDayCheckpointRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_go_live_day_checkpoint_payload(row) for row in rows] + + def save_launch_week_guard_run(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "launch_week_guard_run_id": payload.get("launch_week_guard_run_id") or "launch_week_guard_run_%s" % uuid4().hex[:12], + "launch_wave": payload["launch_wave"], + "account_id": payload.get("account_id"), + "status": payload.get("status", "not_ready"), + "replication_readiness": payload.get("replication_readiness", "not_ready"), + "summary_json": dict(payload.get("summary_json") or payload.get("summary") or {}), + "generated_at": payload.get("generated_at") or utcnow_iso(), } + with self.SessionLocal() as session: + row = session.get(LaunchWeekGuardRunRow, record["launch_week_guard_run_id"]) + if row is None: + row = LaunchWeekGuardRunRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _launch_week_guard_run_payload(row) - def get_auth_identity(self, actor_id: str) -> Dict[str, Any]: + def list_launch_week_guard_runs( + self, + *, + launch_wave: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - row = session.get(AuthIdentityRow, actor_id) + stmt = select(LaunchWeekGuardRunRow).order_by(desc(LaunchWeekGuardRunRow.generated_at)) + if launch_wave is not None: + stmt = stmt.where(LaunchWeekGuardRunRow.launch_wave == launch_wave) + if status is not None: + stmt = stmt.where(LaunchWeekGuardRunRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_launch_week_guard_run_payload(row) for row in rows] + + def save_first_customer_success_pack(self, payload: Dict[str, Any]) -> Dict[str, Any]: + record = { + "first_customer_success_pack_id": payload.get("first_customer_success_pack_id") or "first_customer_success_pack_%s" % uuid4().hex[:12], + "launch_wave": payload["launch_wave"], + "account_id": payload.get("account_id"), + "status": payload.get("status", "not_ready"), + "pack_payload_json": dict(payload.get("pack_payload_json") or payload.get("pack_payload") or {}), + "generated_at": payload.get("generated_at") or utcnow_iso(), + } + with self.SessionLocal() as session: + row = session.get(FirstCustomerSuccessPackRow, record["first_customer_success_pack_id"]) if row is None: - raise KeyError("unknown_auth_identity:%s" % actor_id) - return { - "actor_id": row.actor_id, - "account_id": row.account_id, - "actor_role": row.actor_role, - "display_name": row.display_name, - "password_hash": row.password_hash, - "password_salt": row.password_salt, - "status": row.status, - "created_at": row.created_at, - "updated_at": row.updated_at, - } + row = FirstCustomerSuccessPackRow(**record) + session.add(row) + else: + for key, value in record.items(): + setattr(row, key, value) + session.commit() + session.refresh(row) + return _first_customer_success_pack_payload(row) - def save_auth_token(self, token: Dict[str, Any]) -> Dict[str, Any]: + def list_first_customer_success_packs( + self, + *, + launch_wave: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + with self.SessionLocal() as session: + stmt = select(FirstCustomerSuccessPackRow).order_by(desc(FirstCustomerSuccessPackRow.generated_at)) + if launch_wave is not None: + stmt = stmt.where(FirstCustomerSuccessPackRow.launch_wave == launch_wave) + if status is not None: + stmt = stmt.where(FirstCustomerSuccessPackRow.status == status) + if limit is not None: + stmt = stmt.limit(limit) + rows = session.execute(stmt).scalars().all() + return [_first_customer_success_pack_payload(row) for row in rows] + + def save_provider_subscription(self, subscription: Dict[str, Any]) -> Dict[str, Any]: now = utcnow_iso() payload = { - "token_id": token.get("token_id") or "token_%s" % uuid4().hex[:12], - "actor_id": token["actor_id"], - "account_id": token.get("account_id"), - "actor_role": token["actor_role"], - "token_hash": token["token_hash"], - "status": token.get("status", "active"), - "expires_at": token.get("expires_at"), - "last_used_at": token.get("last_used_at"), + "provider_subscription_id": subscription.get("provider_subscription_id") or "psub_%s" % uuid4().hex[:12], + "account_id": subscription["account_id"], + "tier_id": subscription["tier_id"], + "provider": subscription["provider"], + "provider_ref": subscription.get("provider_ref"), + "provider_customer_id": subscription.get("provider_customer_id"), + "provider_checkout_session_id": subscription.get("provider_checkout_session_id"), + "provider_order_id": subscription.get("provider_order_id"), + "environment": subscription.get("environment", "test"), + "verification_status": subscription.get("verification_status", "pending"), + "last_verified_at": subscription.get("last_verified_at"), + "status": subscription.get("status", "trialing"), + "period_start": subscription.get("period_start"), + "period_end": subscription.get("period_end"), + "cancel_at_period_end": "true" if subscription.get("cancel_at_period_end") else "false", + "latest_event_id": subscription.get("latest_event_id"), + "payload_json": dict(subscription.get("payload_json") or {}), } with self.SessionLocal() as session: - row = session.get(AuthTokenRow, payload["token_id"]) + row = session.get(ProviderSubscriptionRow, payload["provider_subscription_id"]) if row is None: - row = AuthTokenRow(created_at=now, **payload) + row = ProviderSubscriptionRow(created_at=now, updated_at=now, **payload) session.add(row) else: - row.actor_id = payload["actor_id"] row.account_id = payload["account_id"] - row.actor_role = payload["actor_role"] - row.token_hash = payload["token_hash"] + row.tier_id = payload["tier_id"] + row.provider = payload["provider"] + row.provider_ref = payload["provider_ref"] + row.provider_customer_id = payload["provider_customer_id"] + row.provider_checkout_session_id = payload["provider_checkout_session_id"] + row.provider_order_id = payload["provider_order_id"] + row.environment = payload["environment"] + row.verification_status = payload["verification_status"] + row.last_verified_at = payload["last_verified_at"] row.status = payload["status"] - row.expires_at = payload["expires_at"] - row.last_used_at = payload["last_used_at"] + row.period_start = payload["period_start"] + row.period_end = payload["period_end"] + row.cancel_at_period_end = payload["cancel_at_period_end"] + row.latest_event_id = payload["latest_event_id"] + row.payload_json = payload["payload_json"] + row.updated_at = now session.commit() return { **payload, + "cancel_at_period_end": payload["cancel_at_period_end"] == "true", "created_at": now, + "updated_at": now, } - def get_auth_token_by_hash(self, token_hash: str) -> Dict[str, Any]: + def list_provider_subscriptions( + self, + *, + account_id: Optional[str] = None, + provider: Optional[str] = None, + status: Optional[str] = None, + ) -> List[Dict[str, Any]]: with self.SessionLocal() as session: - stmt = select(AuthTokenRow).where(AuthTokenRow.token_hash == token_hash) - row = session.execute(stmt).scalar_one_or_none() - if row is None: - raise KeyError("unknown_auth_token") - return { - "token_id": row.token_id, - "actor_id": row.actor_id, - "account_id": row.account_id, - "actor_role": row.actor_role, - "token_hash": row.token_hash, - "status": row.status, - "created_at": row.created_at, - "expires_at": row.expires_at, - "last_used_at": row.last_used_at, - } + stmt = select(ProviderSubscriptionRow).order_by(desc(ProviderSubscriptionRow.updated_at)) + if account_id is not None: + stmt = stmt.where(ProviderSubscriptionRow.account_id == account_id) + if provider is not None: + stmt = stmt.where(ProviderSubscriptionRow.provider == provider) + if status is not None: + stmt = stmt.where(ProviderSubscriptionRow.status == status) + rows = session.execute(stmt).scalars() + return [ + { + "provider_subscription_id": row.provider_subscription_id, + "account_id": row.account_id, + "tier_id": row.tier_id, + "provider": row.provider, + "provider_ref": row.provider_ref, + "provider_customer_id": row.provider_customer_id, + "provider_checkout_session_id": row.provider_checkout_session_id, + "provider_order_id": row.provider_order_id, + "environment": row.environment, + "verification_status": row.verification_status, + "last_verified_at": row.last_verified_at, + "status": row.status, + "period_start": row.period_start, + "period_end": row.period_end, + "cancel_at_period_end": row.cancel_at_period_end == "true", + "latest_event_id": row.latest_event_id, + "payload_json": dict(row.payload_json or {}), + "created_at": row.created_at, + "updated_at": row.updated_at, + } + for row in rows + ] - def update_auth_token(self, token_id: str, updates: Dict[str, Any]) -> Dict[str, Any]: + def get_provider_subscription_by_ref( + self, + *, + provider: str, + provider_ref: Optional[str] = None, + provider_checkout_session_id: Optional[str] = None, + provider_order_id: Optional[str] = None, + default: Optional[Dict[str, Any]] = ..., + ) -> Optional[Dict[str, Any]]: with self.SessionLocal() as session: - row = session.get(AuthTokenRow, token_id) + stmt = select(ProviderSubscriptionRow).where(ProviderSubscriptionRow.provider == provider) + if provider_ref: + stmt = stmt.where(ProviderSubscriptionRow.provider_ref == provider_ref) + elif provider_checkout_session_id: + stmt = stmt.where(ProviderSubscriptionRow.provider_checkout_session_id == provider_checkout_session_id) + elif provider_order_id: + stmt = stmt.where(ProviderSubscriptionRow.provider_order_id == provider_order_id) + else: + if default is ...: + raise KeyError("provider_subscription_lookup_key_required") + return default + row = session.execute(stmt).scalar_one_or_none() if row is None: - raise KeyError("unknown_auth_token:%s" % token_id) - for key in ["status", "expires_at", "last_used_at", "account_id", "actor_role"]: - if key in updates: - setattr(row, key, updates[key]) - session.commit() + if default is ...: + raise KeyError("unknown_provider_subscription") + return default return { - "token_id": row.token_id, - "actor_id": row.actor_id, + "provider_subscription_id": row.provider_subscription_id, "account_id": row.account_id, - "actor_role": row.actor_role, - "token_hash": row.token_hash, + "tier_id": row.tier_id, + "provider": row.provider, + "provider_ref": row.provider_ref, + "provider_customer_id": row.provider_customer_id, + "provider_checkout_session_id": row.provider_checkout_session_id, + "provider_order_id": row.provider_order_id, + "environment": row.environment, + "verification_status": row.verification_status, + "last_verified_at": row.last_verified_at, "status": row.status, + "period_start": row.period_start, + "period_end": row.period_end, + "cancel_at_period_end": row.cancel_at_period_end == "true", + "latest_event_id": row.latest_event_id, + "payload_json": dict(row.payload_json or {}), "created_at": row.created_at, - "expires_at": row.expires_at, - "last_used_at": row.last_used_at, + "updated_at": row.updated_at, } def save_billing_checkout_session(self, payload: Dict[str, Any]) -> Dict[str, Any]: @@ -1192,7 +8008,9 @@ def save_billing_checkout_session(self, payload: Dict[str, Any]) -> Dict[str, An record = { "checkout_session_id": payload.get("checkout_session_id") or "bcheckout_%s" % uuid4().hex[:12], "account_id": payload["account_id"], + "checkout_kind": payload.get("checkout_kind", "subscription"), "tier_id": payload["tier_id"], + "package_id": payload.get("package_id"), "provider": payload["provider"], "provider_ref": payload.get("provider_ref"), "subscription_id": payload.get("subscription_id"), @@ -1200,6 +8018,7 @@ def save_billing_checkout_session(self, payload: Dict[str, Any]) -> Dict[str, An "checkout_url": payload.get("checkout_url"), "idempotency_key": payload["idempotency_key"], "expires_at": payload.get("expires_at"), + "fulfilled_at": payload.get("fulfilled_at"), } with self.SessionLocal() as session: row = session.get(BillingCheckoutSessionRow, record["checkout_session_id"]) @@ -1208,7 +8027,9 @@ def save_billing_checkout_session(self, payload: Dict[str, Any]) -> Dict[str, An session.add(row) else: row.account_id = record["account_id"] + row.checkout_kind = record["checkout_kind"] row.tier_id = record["tier_id"] + row.package_id = record["package_id"] row.provider = record["provider"] row.provider_ref = record["provider_ref"] row.subscription_id = record["subscription_id"] @@ -1216,6 +8037,7 @@ def save_billing_checkout_session(self, payload: Dict[str, Any]) -> Dict[str, An row.checkout_url = record["checkout_url"] row.idempotency_key = record["idempotency_key"] row.expires_at = record["expires_at"] + row.fulfilled_at = record["fulfilled_at"] row.updated_at = now session.commit() return { @@ -1232,7 +8054,9 @@ def get_billing_checkout_session(self, checkout_session_id: str) -> Dict[str, An return { "checkout_session_id": row.checkout_session_id, "account_id": row.account_id, + "checkout_kind": row.checkout_kind, "tier_id": row.tier_id, + "package_id": row.package_id, "provider": row.provider, "provider_ref": row.provider_ref, "subscription_id": row.subscription_id, @@ -1240,6 +8064,7 @@ def get_billing_checkout_session(self, checkout_session_id: str) -> Dict[str, An "checkout_url": row.checkout_url, "idempotency_key": row.idempotency_key, "expires_at": row.expires_at, + "fulfilled_at": row.fulfilled_at, "created_at": row.created_at, "updated_at": row.updated_at, } @@ -1267,7 +8092,9 @@ def list_billing_checkout_sessions( { "checkout_session_id": row.checkout_session_id, "account_id": row.account_id, + "checkout_kind": row.checkout_kind, "tier_id": row.tier_id, + "package_id": row.package_id, "provider": row.provider, "provider_ref": row.provider_ref, "subscription_id": row.subscription_id, @@ -1275,6 +8102,7 @@ def list_billing_checkout_sessions( "checkout_url": row.checkout_url, "idempotency_key": row.idempotency_key, "expires_at": row.expires_at, + "fulfilled_at": row.fulfilled_at, "created_at": row.created_at, "updated_at": row.updated_at, } @@ -1854,6 +8682,26 @@ def aggregate_eval_metrics( "stale_window_hours": CONTINUATION_STALE_WINDOW_HOURS, }, "quality_signal_correlations": [], + "q03_q09_calibration": { + "coverage_status": "insufficient_coverage", + "sample_count": 0, + "sample_gap": CONTINUATION_TARGET_SAMPLES_PER_WORLD, + "q03": { + "current_thresholds": dict(LONGFORM_Q03_SIGNAL_THRESHOLDS), + "primary_metric": None, + "primary_correlation": None, + "recommendation": "insufficient_coverage", + }, + "q09": { + "current_thresholds": { + "pacing_threshold": float(LONGFORM_SOFT_ISSUE_THRESHOLDS["q09_pacing_threshold"]), + "hook_threshold": float(LONGFORM_SOFT_ISSUE_THRESHOLDS["q09_hook_threshold"]), + }, + "primary_metric": None, + "primary_correlation": None, + "recommendation": "insufficient_coverage", + }, + }, "continuation_world_details": [], "continuation_version_details": [], "continuation_sample_accumulation": { @@ -1964,6 +8812,36 @@ def aggregate_eval_metrics( "q04_present": 1.0 if "Q04" in issue_codes else 0.0, "q05_present": 1.0 if "Q05" in issue_codes else 0.0, "q09_present": 1.0 if "Q09" in issue_codes else 0.0, + "lexical_repetition_score": float( + dict(report.hard_validator_results.get("lint_metrics") or {}).get("lexical_repetition_score", 0.0) or 0.0 + ), + "semantic_paragraph_similarity_score": float( + dict(report.hard_validator_results.get("lint_metrics") or {}).get("semantic_paragraph_similarity_score", 0.0) or 0.0 + ), + "paragraph_similarity_score": float( + dict(report.hard_validator_results.get("lint_metrics") or {}).get("paragraph_similarity_score", 0.0) or 0.0 + ), + "n_gram_repetition_score": float( + dict(report.hard_validator_results.get("lint_metrics") or {}).get("n_gram_repetition_score", 0.0) or 0.0 + ), + "beat_structure_repetition_score": float( + dict(report.hard_validator_results.get("lint_metrics") or {}).get("beat_structure_repetition_score", 0.0) or 0.0 + ), + "event_coverage_gap_score": float( + dict(report.hard_validator_results.get("lint_metrics") or {}).get("event_coverage_gap_score", 0.0) or 0.0 + ), + "beat_coverage_gap_score": float( + dict(report.hard_validator_results.get("lint_metrics") or {}).get("beat_coverage_gap_score", 0.0) or 0.0 + ), + "uncovered_event_count": float( + dict(report.hard_validator_results.get("lint_metrics") or {}).get("uncovered_event_count", 0.0) or 0.0 + ), + "uncovered_beat_count": float( + dict(report.hard_validator_results.get("lint_metrics") or {}).get("uncovered_beat_count", 0.0) or 0.0 + ), + "overcovered_beat_count": float( + dict(report.hard_validator_results.get("lint_metrics") or {}).get("overcovered_beat_count", 0.0) or 0.0 + ), } ) @@ -1982,6 +8860,16 @@ def aggregate_eval_metrics( "q04_present", "q05_present", "q09_present", + "lexical_repetition_score", + "semantic_paragraph_similarity_score", + "paragraph_similarity_score", + "n_gram_repetition_score", + "beat_structure_repetition_score", + "event_coverage_gap_score", + "beat_coverage_gap_score", + "uncovered_event_count", + "uncovered_beat_count", + "overcovered_beat_count", ] def _build_correlation_entries(samples: List[Dict[str, Any]]) -> List[Dict[str, Any]]: correlations: List[Dict[str, Any]] = [] @@ -1997,7 +8885,23 @@ def _build_correlation_entries(samples: List[Dict[str, Any]]) -> List[Dict[str, "correlation": _pearson_correlation(points), "sample_count": len(points), "mean_metric": round(sum(metric_values) / float(max(1, len(metric_values))), 3) if metric_values else 0.0, - "positive_direction": metric_name not in {"issue_count", "q03_present", "q04_present", "q05_present", "q09_present"}, + "positive_direction": metric_name not in { + "issue_count", + "q03_present", + "q04_present", + "q05_present", + "q09_present", + "lexical_repetition_score", + "semantic_paragraph_similarity_score", + "paragraph_similarity_score", + "n_gram_repetition_score", + "beat_structure_repetition_score", + "event_coverage_gap_score", + "beat_coverage_gap_score", + "uncovered_event_count", + "uncovered_beat_count", + "overcovered_beat_count", + }, } ) correlations.sort(key=lambda item: (-abs(float(item["correlation"])), item["metric"])) @@ -2038,11 +8942,24 @@ def _build_signal_summary( correlations = _build_correlation_entries(continuation_samples) overall_correlation = next((item["correlation"] for item in correlations if item["metric"] == "overall_score"), 0.0) aggregate["online_continuation_correlation"] = overall_correlation + aggregate_target_sample_count = ( + CONTINUATION_TARGET_SAMPLES_PER_VERSION + if selected_version_ids is not None and len(selected_version_ids) == 1 + else CONTINUATION_TARGET_SAMPLES_PER_WORLD + ) aggregate["continuation_signal_summary"] = { - **_build_signal_summary(continuation_samples, censored=censored_count), + **_build_signal_summary( + continuation_samples, + censored=censored_count, + target_sample_count=aggregate_target_sample_count, + ), "observed_continue_events": len(continue_events), } aggregate["quality_signal_correlations"] = correlations + aggregate["q03_q09_calibration"] = _build_q03_q09_calibration_summary( + correlations, + aggregate["continuation_signal_summary"], + ) world_samples: Dict[str, List[Dict[str, Any]]] = {} version_samples: Dict[str, List[Dict[str, Any]]] = {} for item in continuation_samples: @@ -2062,6 +8979,14 @@ def _build_signal_summary( ), "top_correlations": world_correlations[:3], "quality_signal_correlations": world_correlations, + "q03_q09_calibration": _build_q03_q09_calibration_summary( + world_correlations, + _build_signal_summary( + samples, + censored=censored_world_counts.get(current_world_id, 0), + target_sample_count=CONTINUATION_TARGET_SAMPLES_PER_WORLD, + ), + ), **_build_signal_summary( samples, censored=censored_world_counts.get(current_world_id, 0), @@ -2090,6 +9015,14 @@ def _build_signal_summary( ), "top_correlations": version_correlations[:3], "quality_signal_correlations": version_correlations, + "q03_q09_calibration": _build_q03_q09_calibration_summary( + version_correlations, + _build_signal_summary( + samples, + censored=censored_version_counts.get(current_world_version_id, 0), + target_sample_count=CONTINUATION_TARGET_SAMPLES_PER_VERSION, + ), + ), **_build_signal_summary( samples, censored=censored_version_counts.get(current_world_version_id, 0), @@ -2133,8 +9066,11 @@ def record_analytics_event(self, payload: Dict[str, Any]) -> Dict[str, Any]: return { "event_id": row.event_id, "event_name": row.event_name, + "reader_id": row.reader_id, "session_id": row.session_id, "world_version_id": row.world_version_id, + "payload_json": dict(row.payload_json or {}), + "occurred_at": row.occurred_at, } def list_analytics_events( diff --git a/src/narrativeos/pipeline.py b/src/narrativeos/pipeline.py index 0008886..ee5fcce 100644 --- a/src/narrativeos/pipeline.py +++ b/src/narrativeos/pipeline.py @@ -3,6 +3,7 @@ from typing import Callable, Dict, List, Optional, Sequence, Tuple from .critics import BaseCritic, default_critics +from .longform import archive_longform_chapter, build_longform_context_pack, default_chapter_task, longform_terminal_allowed, sync_longform_progression from .memory import advance_story_phase_if_needed, apply_event from .models import ( ChapterPlan, @@ -18,7 +19,7 @@ from .providers import CandidateProvider, StaticCandidateProvider from .rendering import Renderer, TemplateRenderer from .scene_functions import is_terminal_scene_function -from .search import beam_search, evaluate_candidates +from .search import evaluate_candidates SCENE_INTENTS = { @@ -319,6 +320,137 @@ def _progression_event_target(phase: str, beat_target: int) -> int: return max(1, min(desired, beat_target)) +def _adaptive_candidate_budget( + state: NarrativeState, + *, + min_candidates: int, + max_candidates: int, +) -> Tuple[int, int]: + progression = dict((state.metadata or {}).get("longform_progression") or {}) + series_target_chapters = int(progression.get("series_target_chapters", 0) or 0) + current_chapter = int(progression.get("series_chapter_index", state.chapter_index or 0) or state.chapter_index or 0) + diagnostics_mode = str((state.metadata or {}).get("longform_diagnostics_mode") or "") + if series_target_chapters < 1000: + return min_candidates, max_candidates + if diagnostics_mode == "longform_1000": + if current_chapter >= 900: + return max(2, min(min_candidates, 2)), max(3, min(max_candidates, 3)) + if current_chapter >= 800: + return max(2, min(min_candidates, 3)), max(4, min(max_candidates, 4)) + if current_chapter >= 700: + return max(3, min(min_candidates, 4)), max(5, min(max_candidates, 5)) + if current_chapter >= 800: + return max(3, min(min_candidates, 3)), max(4, min(max_candidates, 4)) + if current_chapter >= 750: + return max(3, min(min_candidates, 4)), max(5, min(max_candidates, 5)) + if current_chapter >= 600: + return max(4, min(min_candidates, 5)), max(7, min(max_candidates, 8)) + return min_candidates, max_candidates + + +def _adaptive_beat_target(state: NarrativeState, beat_target: int) -> int: + progression = dict((state.metadata or {}).get("longform_progression") or {}) + series_target_chapters = int(progression.get("series_target_chapters", 0) or 0) + current_chapter = int(progression.get("series_chapter_index", state.chapter_index or 0) or state.chapter_index or 0) + diagnostics_mode = str((state.metadata or {}).get("longform_diagnostics_mode") or "") + if series_target_chapters < 1000: + return beat_target + if diagnostics_mode == "longform_1000": + if current_chapter >= 900: + return min(2, beat_target) + if current_chapter >= 750: + return min(3, beat_target) + if current_chapter >= 800: + return min(3, beat_target) + if current_chapter >= 600: + return min(4, beat_target) + return beat_target + + +def _adaptive_progression_target( + state: NarrativeState, + progression_target: int, +) -> int: + progression = dict((state.metadata or {}).get("longform_progression") or {}) + series_target_chapters = int(progression.get("series_target_chapters", 0) or 0) + current_chapter = int(progression.get("series_chapter_index", state.chapter_index or 0) or state.chapter_index or 0) + diagnostics_mode = str((state.metadata or {}).get("longform_diagnostics_mode") or "") + duty_type = str((state.current_chapter_task or {}).get("duty_type") or "") + overdue_open_promises = sum( + 1 + for promise in state.open_promises + if getattr(promise, "status", "") == "open" and int(getattr(promise, "due_by_turn", 0) or 0) <= int(state.turn_index or 0) + ) + if state.story_phase == "aftermath" and progression_target < 2 and ( + len(state.open_promises) >= 3 + or overdue_open_promises > 0 + or duty_type in {"pace_breath", "expand_world", "resolve_promise", "advance_relationship", "deliver_climax"} + ): + progression_target = 2 + if state.story_phase == "aftermath" and duty_type in {"advance_relationship", "deliver_climax"}: + progression_target = max(progression_target, 3) + if state.story_phase == "aftermath" and duty_type in {"resolve_promise", "expand_world"} and ( + len(state.open_promises) >= 2 or overdue_open_promises > 0 + ): + progression_target = max(progression_target, 3) + if series_target_chapters < 1000: + return progression_target + if diagnostics_mode == "longform_1000": + if current_chapter >= 900: + return min(1, progression_target) + if current_chapter >= 750: + return min(2, progression_target) + if current_chapter >= 800: + return min(2, progression_target) + return progression_target + + +def _adaptive_search_depth(state: NarrativeState, requested_depth: int) -> int: + progression = dict((state.metadata or {}).get("longform_progression") or {}) + series_target_chapters = int(progression.get("series_target_chapters", 0) or 0) + current_chapter = int(progression.get("series_chapter_index", state.chapter_index or 0) or state.chapter_index or 0) + diagnostics_mode = str((state.metadata or {}).get("longform_diagnostics_mode") or "") + if series_target_chapters < 1000: + return requested_depth + if diagnostics_mode == "longform_1000": + if current_chapter >= 900: + return 0 + if current_chapter >= 750: + return min(1, requested_depth) + if current_chapter >= 800: + return min(1, requested_depth) + if current_chapter >= 600: + return min(1, requested_depth) + return requested_depth + + +def _budget_profile_for_state( + state: NarrativeState, + *, + requested_beat_target: int, + min_candidates: int, + max_candidates: int, +) -> Dict[str, object]: + adapted_beat_target = _adaptive_beat_target(state, requested_beat_target) + adapted_progression_target = _adaptive_progression_target( + state, + _progression_event_target(state.story_phase, len(BEAT_BLUEPRINTS.get(adapted_beat_target, BEAT_BLUEPRINTS[3]))), + ) + adapted_min_candidates, adapted_max_candidates = _adaptive_candidate_budget( + state, + min_candidates=min_candidates, + max_candidates=max_candidates, + ) + return { + "diagnostics_mode": str((state.metadata or {}).get("longform_diagnostics_mode") or ""), + "requested_beat_target": requested_beat_target, + "adapted_beat_target": adapted_beat_target, + "adapted_progression_target": adapted_progression_target, + "adapted_min_candidates": adapted_min_candidates, + "adapted_max_candidates": adapted_max_candidates, + } + + def _score_scene_fit(scene_intent: SceneIntent, event: EventAtom) -> float: score = 0.0 if event.scene_function in scene_intent.preferred_scene_functions: @@ -380,22 +512,116 @@ def _render_spec_for_scene(state: NarrativeState, scene_intent: SceneIntent) -> "climax": "manhua_drama", "aftermath": "novel_light", }.get(state.story_phase, "novel_lush") + base_target_word_count = max(int(state.word_budget or 2000), 2000) + authoring_surface = str((state.metadata or {}).get("authoring_surface") or "") + if authoring_surface == "author_work_generation": + target_word_count = min(max(base_target_word_count, 1800), 2000) + min_target_word_count = 1800 + max_target_word_count = 2200 + elif state.story_phase in {"setup", "early_rising"}: + target_word_count = base_target_word_count + min_target_word_count = max(1800, target_word_count - 200) + max_target_word_count = max(target_word_count, target_word_count + 200) + else: + target_word_count = base_target_word_count + min_target_word_count = max(200, target_word_count - 200) + max_target_word_count = max(target_word_count, target_word_count + 200) return SceneRenderSpec( prose_mode=prose_mode, viewpoint_character="", - target_word_count={ - "novel_light": 650, - "novel_lush": 950, - "manhua_drama": 780, - }[prose_mode], + target_word_count=target_word_count, dialogue_density=0.32 if prose_mode == "novel_light" else (0.4 if prose_mode == "manhua_drama" else 0.35), sensory_motifs=scene_intent.preferred_tags[:3], emotional_pivot=scene_intent.label, ending_cadence="lingering" if prose_mode != "manhua_drama" else "hard_cut", + min_target_word_count=min_target_word_count, + max_target_word_count=max_target_word_count, must_include_beats=[scene_intent.label], ) +def _trace_entry_from_scored_candidate(candidate) -> Dict[str, object]: + return { + "event_id": candidate.event.event_id, + "total_score": candidate.total_score, + "critic_penalty": candidate.critic_penalty, + "components": dict(candidate.components), + "critic_decisions": [ + decision.to_dict() for decision in candidate.critic_decisions + ], + "explanation": candidate.explanation, + } + + +def _chosen_candidate_summary(chosen_trace: Sequence[Dict[str, object]]) -> Dict[str, object]: + if not chosen_trace: + return {} + first = dict(chosen_trace[0] or {}) + return { + "event_id": first.get("event_id"), + "total_score": first.get("total_score"), + "critic_penalty": first.get("critic_penalty"), + "components": dict(first.get("components") or {}), + "critic_decisions": list(first.get("critic_decisions") or []), + "explanation": first.get("explanation"), + } + + +def _debug_route_from_scene_beats( + scene_beats: Sequence[SceneBeat], + chosen_trace: Sequence[Dict[str, object]], +) -> Dict[str, object]: + events = [beat.event.to_dict() for beat in scene_beats] + total_score = round( + sum(float(item.get("total_score", 0.0) or 0.0) for item in chosen_trace), + 3, + ) + component_totals: Dict[str, float] = {} + for item in chosen_trace: + for key, value in dict(item.get("components") or {}).items(): + component_totals[str(key)] = component_totals.get(str(key), 0.0) + float(value or 0.0) + event_count = max(1, len(scene_beats)) + score_breakdown = { + key: round(value / float(event_count), 3) + for key, value in component_totals.items() + } + event_ids = [event["event_id"] for event in events if event.get("event_id")] + return { + "events": events, + "event_ids": event_ids, + "total_score": total_score, + "score_breakdown": score_breakdown, + "critic_trace": [dict(item) for item in chosen_trace], + "explanation": "scene_route=%s; total_score=%.3f" + % (" -> ".join(event_ids), total_score), + } + + +def _projected_followup_event( + source_event: EventAtom, + *, + dramatic_job: str, + beat_index: int, +) -> EventAtom: + payload = source_event.to_dict() + label = { + "pivot": "真正要转向的那句终于逼到眼前", + "aftermath": "说出口后的余波开始追到账前", + "echo": "没认完的后半句顺着回声追上来", + }.get(dramatic_job, "这一拍留下来的余波开始显形") + payload["event_id"] = f"{source_event.event_id}__beat_projection__{beat_index}_{dramatic_job}" + payload["title"] = f"{source_event.title} · {label}" + location = source_event.location or "原处" + payload["summary"] = f"{label}继续压在{location}里,刚才没说透的态度、代价和退路都被逼到明处。" + payload["metadata"] = { + **dict(payload.get("metadata") or {}), + "beat_projection": True, + "beat_projection_of": source_event.event_id, + "beat_projection_job": dramatic_job, + } + return EventAtom.from_dict(payload) + + def simulate_scene_beats( state: NarrativeState, *, @@ -408,24 +634,37 @@ def simulate_scene_beats( candidate_reranker: Optional[Callable[..., Dict[str, object]]] = None, min_candidates: int = 6, max_candidates: int = 10, -) -> Tuple[List[SceneBeat], NarrativeState, List[Dict[str, object]]]: + ) -> Tuple[List[SceneBeat], NarrativeState, List[Dict[str, object]], Dict[str, object]]: current_state = NarrativeState.from_dict(state.to_dict()) scene_beats: List[SceneBeat] = [] chosen_events: List[EventAtom] = [] rerank_receipts: List[Dict[str, object]] = [] + first_candidate_batch = None + first_scored_candidates = [] + chosen_trace: List[Dict[str, object]] = [] + beat_candidate_trace: List[Dict[str, object]] = [] + beat_target = _adaptive_beat_target(state, beat_target) beat_blueprint = BEAT_BLUEPRINTS.get(beat_target, BEAT_BLUEPRINTS[3]) - progression_target = _progression_event_target(state.story_phase, len(beat_blueprint)) + progression_target = _adaptive_progression_target( + state, + _progression_event_target(state.story_phase, len(beat_blueprint)), + ) for beat_index, (prefix, job) in enumerate(beat_blueprint, start=1): if beat_index > progression_target: if not chosen_events: break echo_source = chosen_events[-1] if job in {"pivot", "aftermath", "echo"} else chosen_events[0] + projected_event = _projected_followup_event( + echo_source, + dramatic_job=job, + beat_index=beat_index, + ) scene_beats.append( SceneBeat( beat_index=beat_index, - event=echo_source, - beat_label="%s:%s" % (prefix, echo_source.title), + event=projected_event, + beat_label="%s:%s" % (prefix, projected_event.title), dramatic_job=job, tension_after=current_state.tension, ) @@ -438,11 +677,27 @@ def simulate_scene_beats( candidate_provider=candidate_provider, critics=critics, weights=weights, - depth=min(beat_index - 1, 2), + depth=_adaptive_search_depth(state, min(beat_index - 1, 2)), min_candidates=min_candidates, max_candidates=max_candidates, ) + if first_candidate_batch is None: + first_candidate_batch = candidate_batch + first_scored_candidates = list(scored_candidates) + beat_trace_entry = { + "beat_index": beat_index, + "dramatic_job": job, + "search_depth": _adaptive_search_depth(state, min(beat_index - 1, 2)), + "requested_min_candidates": min_candidates, + "requested_max_candidates": max_candidates, + "raw_candidate_count": len(list(candidate_batch.raw_candidates or [])), + "legal_candidate_count": len(list(candidate_batch.legal_candidates or [])), + "scored_candidate_count": len(list(scored_candidates or [])), + "critic_rejection_count": len(list((candidate_batch.debug or {}).get("critic_rejections", []) or [])), + "evaluate_candidates_timing_ms": dict((candidate_batch.debug or {}).get("timing_ms") or {}), + } if not scored_candidates: + beat_candidate_trace.append(beat_trace_entry) break ranked_candidates = sorted( @@ -478,6 +733,7 @@ def simulate_scene_beats( receipt = rerank_result.get("receipt") if receipt: rerank_receipts.append(dict(receipt)) + beat_trace_entry["ranked_candidate_count"] = len(list(ranked_candidates or [])) chosen_candidate = next( ( @@ -488,6 +744,9 @@ def simulate_scene_beats( ranked_candidates[0], ) chosen_event = chosen_candidate.event + beat_trace_entry["selected_event_id"] = chosen_event.event_id + chosen_trace.append(_trace_entry_from_scored_candidate(chosen_candidate)) + beat_candidate_trace.append(beat_trace_entry) current_state = apply_event(current_state, chosen_event) chosen_events.append(chosen_event) scene_beats.append( @@ -500,7 +759,12 @@ def simulate_scene_beats( ) ) - return scene_beats, current_state, rerank_receipts + return scene_beats, current_state, rerank_receipts, { + "first_candidate_batch": first_candidate_batch, + "first_scored_candidates": list(first_scored_candidates), + "chosen_trace": list(chosen_trace), + "beat_candidate_trace": list(beat_candidate_trace), + } def plan_next_scene( @@ -513,11 +777,18 @@ def plan_next_scene( candidate_reranker: Optional[Callable[..., Dict[str, object]]] = None, min_candidates: int = 6, max_candidates: int = 10, -) -> Tuple[Optional[ChapterPlan], List[SceneBeat], NarrativeState, SceneRenderSpec, List[Dict[str, object]]]: - scene_intent = _pick_scene_intent(state, world) +) -> Tuple[Optional[ChapterPlan], List[SceneBeat], NarrativeState, SceneRenderSpec, List[Dict[str, object]], Dict[str, object]]: + planning_state = NarrativeState.from_dict(state.to_dict()) + sync_longform_progression(planning_state, world) + scene_intent = _pick_scene_intent(planning_state, world) beat_target = _beat_target_for_phase(state.story_phase) - scene_beats, scene_state, rerank_receipts = simulate_scene_beats( - state, + budgeted_min_candidates, budgeted_max_candidates = _adaptive_candidate_budget( + planning_state, + min_candidates=min_candidates, + max_candidates=max_candidates, + ) + scene_beats, scene_state, rerank_receipts, search_trace = simulate_scene_beats( + planning_state, world=world, candidate_provider=candidate_provider, critics=critics, @@ -525,25 +796,52 @@ def plan_next_scene( scene_intent=scene_intent, beat_target=beat_target, candidate_reranker=candidate_reranker, - min_candidates=min_candidates, - max_candidates=max_candidates, + min_candidates=budgeted_min_candidates, + max_candidates=budgeted_max_candidates, ) if not scene_beats: - return None, [], state, _render_spec_for_scene(state, scene_intent), rerank_receipts + return None, [], state, _render_spec_for_scene(state, scene_intent), rerank_receipts, search_trace finalized_state = NarrativeState.from_dict(scene_state.to_dict()) advance_story_phase_if_needed(finalized_state, scene_intent_id=scene_intent.intent_id) + longform_progression = sync_longform_progression(finalized_state, world) + chapter_task = dict(longform_progression.get("chapter_task") or default_chapter_task(finalized_state, world)) + finalized_state.current_chapter_task = dict(chapter_task) render_spec = _render_spec_for_scene(finalized_state, scene_intent) + selected_event_ids = list(dict.fromkeys(beat.event.event_id for beat in scene_beats)) chapter_plan = ChapterPlan( chapter_index=finalized_state.chapter_index, story_phase=finalized_state.story_phase, scene_intent=scene_intent, beat_target=beat_target, beat_count=len(scene_beats), - ending_ready=is_terminal_scene_function(scene_beats[-1].event.scene_function, scene_beats[-1].event.metadata), - selected_event_ids=[beat.event.event_id for beat in scene_beats], + ending_ready=( + is_terminal_scene_function(scene_beats[-1].event.scene_function, scene_beats[-1].event.metadata) + and longform_terminal_allowed(finalized_state, chapter_task, scene_beats[-1].event) + ), + selected_event_ids=selected_event_ids, + chapter_task=dict(chapter_task), + chapter_task_execution_summary={ + "duty_type": chapter_task.get("duty_type"), + "target_words": chapter_task.get("target_words"), + "reveal_budget": chapter_task.get("reveal_budget"), + "promise_actions": list(chapter_task.get("promise_actions", [])), + "selected_event_count": len(selected_event_ids), + "series_chapter_index": longform_progression.get("series_chapter_index"), + "series_target_chapters": longform_progression.get("series_target_chapters"), + "volume_id": longform_progression.get("volume_id"), + "volume_chapter_index": longform_progression.get("volume_chapter_index"), + "volume_target_chapters": longform_progression.get("volume_target_chapters"), + "arc_id": longform_progression.get("arc_id"), + "arc_chapter_index": longform_progression.get("arc_chapter_index"), + "arc_target_chapters": longform_progression.get("arc_target_chapters"), + "task_sequence_index": longform_progression.get("task_sequence_index"), + "used_fallback": bool(longform_progression.get("used_fallback", False)), + "ending_gate_blocked": is_terminal_scene_function(scene_beats[-1].event.scene_function, scene_beats[-1].event.metadata) + and not longform_terminal_allowed(finalized_state, chapter_task, scene_beats[-1].event), + }, ) - return chapter_plan, scene_beats, finalized_state, render_spec, rerank_receipts + return chapter_plan, scene_beats, finalized_state, render_spec, rerank_receipts, search_trace def render_scene( @@ -594,29 +892,7 @@ def plan_next_turn( active_renderer = renderer or TemplateRenderer() resolved_weights = resolve_search_weights(world, weights=weights) - candidate_batch, scored_candidates = evaluate_candidates( - state, - world, - candidate_provider=candidate_provider, - critics=active_critics, - weights=resolved_weights, - depth=0, - min_candidates=min_candidates, - max_candidates=max_candidates, - ) - routes = beam_search( - state, - world=world, - candidate_provider=candidate_provider, - critics=active_critics, - depth=depth, - beam_width=beam_width, - weights=resolved_weights, - min_candidates=min_candidates, - max_candidates=max_candidates, - ) - - chapter_plan, scene_beats, updated_state, render_spec, assisted_rerank_receipts = plan_next_scene( + chapter_plan, scene_beats, updated_state, render_spec, assisted_rerank_receipts, search_trace = plan_next_scene( state, world=world, candidate_provider=candidate_provider, @@ -626,22 +902,61 @@ def plan_next_turn( min_candidates=min_candidates, max_candidates=max_candidates, ) + debug_candidate_batch = ( + search_trace.get("first_candidate_batch") + if isinstance(search_trace, dict) + else None + ) + debug_scored_candidates = list( + search_trace.get("first_scored_candidates") or [] + ) if isinstance(search_trace, dict) else [] + debug_critic_trace = list( + search_trace.get("chosen_trace") or [] + ) if isinstance(search_trace, dict) else [] + beat_candidate_trace = list( + search_trace.get("beat_candidate_trace") or [] + ) if isinstance(search_trace, dict) else [] + debug_routes = ( + [_debug_route_from_scene_beats(scene_beats, debug_critic_trace)] + if scene_beats + else [] + ) + planner_trace_summary = { + **_budget_profile_for_state( + state, + requested_beat_target=_beat_target_for_phase(state.story_phase), + min_candidates=min_candidates, + max_candidates=max_candidates, + ), + "per_beat": beat_candidate_trace, + "max_raw_candidate_count": max((int(item.get("raw_candidate_count", 0) or 0) for item in beat_candidate_trace), default=0), + "max_scored_candidate_count": max((int(item.get("scored_candidate_count", 0) or 0) for item in beat_candidate_trace), default=0), + "max_provider_latency_ms": max((float(dict(item.get("evaluate_candidates_timing_ms") or {}).get("provider", 0.0) or 0.0) for item in beat_candidate_trace), default=0.0), + "max_critics_latency_ms": max((float(dict(item.get("evaluate_candidates_timing_ms") or {}).get("critics", 0.0) or 0.0) for item in beat_candidate_trace), default=0.0), + "max_scoring_latency_ms": max((float(dict(item.get("evaluate_candidates_timing_ms") or {}).get("scoring", 0.0) or 0.0) for item in beat_candidate_trace), default=0.0), + "max_sort_latency_ms": max((float(dict(item.get("evaluate_candidates_timing_ms") or {}).get("sort", 0.0) or 0.0) for item in beat_candidate_trace), default=0.0), + "max_total_evaluate_latency_ms": max((float(dict(item.get("evaluate_candidates_timing_ms") or {}).get("total", 0.0) or 0.0) for item in beat_candidate_trace), default=0.0), + } + chosen_candidate_summary = _chosen_candidate_summary(debug_critic_trace) if chapter_plan is None or not scene_beats: return { "status": "no_legal_routes", "reader_view": None, "updated_state_summary": _state_summary(state), "replay_preview": {"chapter_index": state.chapter_index, "latest_title": None}, - "candidate_batch": candidate_batch.to_dict(), - "scored_candidates": [candidate.to_dict() for candidate in scored_candidates], - "routes": [route.to_dict() for route in routes], - "critic_trace": [], + "candidate_batch": debug_candidate_batch.to_dict() if debug_candidate_batch is not None else {"raw_candidates": [], "legal_candidates": [], "illegal_candidate_reasons": {}, "debug": {}}, + "scored_candidates": [candidate.to_dict() for candidate in debug_scored_candidates], + "routes": debug_routes, + "critic_trace": debug_critic_trace, "rendered_scene": None, "updated_state": state.to_dict(), "chapter_plan": None, "scene_beats": [], "scene_render_spec": render_spec.to_dict(), "assisted_rerank_receipts": assisted_rerank_receipts, + "longform_context_pack": build_longform_context_pack(state), + "planner_trace_summary": planner_trace_summary, + "chosen_candidate_summary": chosen_candidate_summary, } rendered_scene = active_renderer.render_scene( @@ -660,6 +975,13 @@ def plan_next_turn( scene_beats, rendered_scene, ) + updated_state = archive_longform_chapter( + updated_state, + chapter_plan=chapter_plan, + chosen_event=scene_beats[0].event, + rendered_body=reader_view.body, + ) + longform_context_pack = build_longform_context_pack(updated_state) response = { "status": "ok", @@ -669,23 +991,26 @@ def plan_next_turn( "chapter_index": updated_state.chapter_index, "latest_title": reader_view.chapter_title, }, + "chosen_event": scene_beats[0].event.to_dict(), + "updated_state": updated_state.to_dict(), + "chapter_plan": chapter_plan.to_dict(), + "planner_trace_summary": planner_trace_summary, + "chosen_candidate_summary": chosen_candidate_summary, } if debug: response.update( { - "chosen_event": scene_beats[0].event.to_dict(), - "updated_state": updated_state.to_dict(), - "best_route_event_ids": [event.event_id for event in routes[0].events] if routes else [], - "candidate_batch": candidate_batch.to_dict(), - "scored_candidates": [candidate.to_dict() for candidate in scored_candidates], - "routes": [route.to_dict() for route in routes], - "critic_trace": routes[0].critic_trace if routes else [], + "best_route_event_ids": list(debug_routes[0].get("event_ids", [])) if debug_routes else [], + "candidate_batch": debug_candidate_batch.to_dict() if debug_candidate_batch is not None else {"raw_candidates": [], "legal_candidates": [], "illegal_candidate_reasons": {}, "debug": {}}, + "scored_candidates": [candidate.to_dict() for candidate in debug_scored_candidates], + "routes": debug_routes, + "critic_trace": debug_critic_trace, "rendered_scene": rendered_scene.to_dict(), - "chapter_plan": chapter_plan.to_dict(), "scene_beats": [beat.to_dict() for beat in scene_beats], "scene_render_spec": render_spec.to_dict(), "assisted_rerank_receipts": assisted_rerank_receipts, + "longform_context_pack": longform_context_pack, } ) diff --git a/src/narrativeos/presenter.py b/src/narrativeos/presenter.py index 4b63f61..6046bba 100644 --- a/src/narrativeos/presenter.py +++ b/src/narrativeos/presenter.py @@ -1,10 +1,16 @@ from __future__ import annotations -from typing import List, Sequence +from typing import Dict, List, Sequence from .models import ChapterPlan, NarrativeState, NarrativeViewModel, RenderedScene, SceneBeat, WorldBible from .relationship_graph import summarize_relationship_changes -from .sanitizer import sanitize_lines, sanitize_text +from .sanitizer import ( + sanitize_lines, + sanitize_reader_visible_lines, + sanitize_reader_visible_text, + sanitize_reader_visible_text_with_report, + sanitize_text, +) def _display_name(state: NarrativeState, actor_id: str) -> str: @@ -101,27 +107,50 @@ def present_scene_for_reader( scene_beats: Sequence[SceneBeat], rendered_scene: RenderedScene, ) -> NarrativeViewModel: + language_debug: Dict[str, object] = { + "reader_visible_language_sanitized": False, + "sanitized_latin_tokens": [], + "fields": [], + } + + def sanitize_field(name: str, value: str) -> str: + cleaned, report = sanitize_reader_visible_text_with_report(value) + if report["reader_visible_language_sanitized"]: + language_debug["reader_visible_language_sanitized"] = True + language_debug["fields"].append(name) + language_debug["sanitized_latin_tokens"] = list( + dict.fromkeys( + list(language_debug["sanitized_latin_tokens"]) + + list(report["sanitized_latin_tokens"]) + ) + ) + return cleaned + recap_lines = [] for previous_title in state_before.timeline[-2:]: recap_lines.append(previous_title) - recap = sanitize_text("前情提要:" + ";".join(recap_lines)) if recap_lines else "故事刚刚开始。" + recap = sanitize_field("recap", "前情提要:" + ";".join(recap_lines)) if recap_lines else "故事刚刚开始。" scene_card = { - "title": sanitize_text(rendered_scene.story_title or chapter_plan.scene_intent.label), - "summary": sanitize_text(rendered_scene.chapter_summary or rendered_scene.image_caption), - "quote": sanitize_text(rendered_scene.pull_quote), - "palette_hint": sanitize_text(rendered_scene.palette_hint or ",".join(world.creator_controls.theme_targets[:2])), - "story_beats": sanitize_lines(rendered_scene.story_beats), - "visual_details": sanitize_lines(rendered_scene.visual_details), + "title": sanitize_field("scene_card.title", rendered_scene.story_title or chapter_plan.scene_intent.label), + "summary": sanitize_field("scene_card.summary", rendered_scene.chapter_summary or rendered_scene.image_caption), + "quote": sanitize_field("scene_card.quote", rendered_scene.pull_quote), + "palette_hint": sanitize_field("scene_card.palette_hint", rendered_scene.palette_hint or ",".join(world.creator_controls.theme_targets[:2])), + "story_beats": [sanitize_field(f"scene_card.story_beats[{index}]", item) for index, item in enumerate(rendered_scene.story_beats)], + "visual_details": [sanitize_field(f"scene_card.visual_details[{index}]", item) for index, item in enumerate(rendered_scene.visual_details)], } + rendered_scene.debug.setdefault("reader_visible_language_debug", language_debug) return NarrativeViewModel( - chapter_title=sanitize_text(rendered_scene.story_title or chapter_plan.scene_intent.label), + chapter_title=sanitize_field("chapter_title", rendered_scene.story_title or chapter_plan.scene_intent.label), chapter_index=state_after.chapter_index, recap=recap, - body=sanitize_text(rendered_scene.premium_prose), + body=sanitize_field("body", rendered_scene.premium_prose), scene_card=scene_card, - choices=_reader_choices(scene_beats), - relationship_hints=_relationship_hints(state_before, state_after, scene_beats), + choices=[sanitize_field(f"choice[{index}]", item) for index, item in enumerate(_reader_choices(scene_beats))], + relationship_hints=[ + sanitize_field(f"relationship_hint[{index}]", item) + for index, item in enumerate(_relationship_hints(state_before, state_after, scene_beats)) + ], can_continue=state_after.story_phase != "aftermath", ) diff --git a/src/narrativeos/prompts.py b/src/narrativeos/prompts.py index b2b3656..ce01f40 100644 --- a/src/narrativeos/prompts.py +++ b/src/narrativeos/prompts.py @@ -2,8 +2,10 @@ import json from pathlib import Path +from typing import List, Optional -from .models import EventAtom, NarrativeState, WorldBible +from .models import ChapterPlan, EventAtom, NarrativeState, SceneBeat, SceneRenderSpec, WorldBible +from .quality.hard_constraints import build_generation_hard_constraint_prompt_contract PROMPT_DIR = Path(__file__).resolve().parents[2] / "prompts" @@ -47,12 +49,26 @@ def render_scene_user_prompt( state_before: NarrativeState, state_after: NarrativeState, event: EventAtom, + chapter_plan: Optional[ChapterPlan] = None, + scene_beats: Optional[List[SceneBeat]] = None, + render_spec: Optional[SceneRenderSpec] = None, ) -> str: + target_chapters = int(getattr(getattr(world, "series_plan", None), "total_chapter_target", 0) or 0) payload = { "task": "render_scene", "world": world.to_dict(), "state_before": state_before.to_dict(), "state_after": state_after.to_dict(), "event": event.to_dict(), + "generation_hard_constraints": build_generation_hard_constraint_prompt_contract( + target_chapters=target_chapters, + worldpack_payload=world.to_dict(), + ), } + if chapter_plan is not None: + payload["chapter_plan"] = chapter_plan.to_dict() + if scene_beats is not None: + payload["scene_beats"] = [beat.to_dict() for beat in scene_beats] + if render_spec is not None: + payload["render_spec"] = render_spec.to_dict() return json.dumps(payload, ensure_ascii=False, indent=2) diff --git a/src/narrativeos/prose_linter.py b/src/narrativeos/prose_linter.py index 98fde6f..fbd39f0 100644 --- a/src/narrativeos/prose_linter.py +++ b/src/narrativeos/prose_linter.py @@ -1,16 +1,38 @@ from __future__ import annotations +from copy import deepcopy +from functools import lru_cache import re from typing import Dict, List from .meta_leak_detector import detect_meta_leaks, meta_sentence_rate -from .repetition_detector import repetition_score +from .repetition_detector import repetition_score, repetition_signal_bundle from .sanitizer import sanitize_text from .style_sanitizer import style_sanitize -DETAIL_MARKERS = ["灯", "袖", "茶", "风", "门", "阶", "檐", "影", "衣", "案", "纸", "雨", "香", "窗", "灯影"] -ACTION_MARKERS = ["抬", "落", "偏", "按", "握", "退", "站", "看", "拢", "推", "折", "停", "走", "靠", "咽"] +DETAIL_MARKERS = [ + "灯", "袖", "茶", "风", "门", "阶", "檐", "影", "衣", "案", "纸", "雨", "香", "窗", "灯影", + "栏", "栏杆", "杯", "杯沿", "门框", "木板", "纸页", "桌沿", "桌角", "器物", "石径", "叶影", + "扫描台", "蓝线", "红灯", "防潮盒", "钝印", "胶痕", "签章", "声纹", "画稿", "盐壳", "录音笔", "话筒", + "石砖", "空杯", "窗纸", "木栏", "地板", "檐角", "冷光", "回声", "香灰", "笔架", "卷面", "号板", + "墨迹", "鞋底", "手背", "发梢", "灰尘", "水痕", "潮气", "湿气", "衣摆", "袖口", + "指节", "呼吸", "肩背", "掌心", "眼睫", "廊柱", "石阶", "花枝", "帘钩", "玉佩", "朱批", "折角", + "灯座", "玉阶", "香炉", "钟声", "檀香", "冷雾", "山门", "剑穗", "符纸", "云气", "霜意", + "湖面", "石栏", "水声", "水雾", "月色", "水线", "浪声", "水滴声", "盐味", "潮痕", + "雨棚", "旧门牌", "雨伞骨", "监控探头", "电流声", "鞋底水声", "翻卷声", "霓虹", "湿雾", "油烟", +] +ACTION_MARKERS = ["抬", "落", "偏", "按", "握", "退", "站", "看", "拢", "推", "折", "停", "走", "靠", "咽", "压", "掠", "碰", "擦", "收", "绷", "卷", "撞", "回", "拨", "绕", "贴", "拖"] +LATIN_TOKEN_PATTERN = re.compile(r"[A-Za-z]+") +LATIN_TOKEN_WHITELIST_PATTERN = re.compile(r"^[A-Z]{2,}$") + + +def _normalize_visible_text_for_language_scan(text: str) -> str: + cleaned = style_sanitize(str(text or "")) + cleaned = re.sub(r"[ \t]{2,}", " ", cleaned) + cleaned = re.sub(r" *\n *", "\n", cleaned) + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + return cleaned.strip() def _split_paragraphs(text: str) -> List[str]: @@ -29,10 +51,46 @@ def _count_details(text: str) -> int: return sum(text.count(marker) for marker in DETAIL_MARKERS) +def story_text_unit_count(text: str) -> int: + normalized = re.sub(r"\s+", "", str(text or "")) + return len(re.findall(r"[\u4e00-\u9fffA-Za-z0-9]", normalized)) + + +@lru_cache(maxsize=512) +def _cached_repetition_signal_bundle(cleaned_paragraphs: tuple[str, ...]) -> Dict[str, object]: + return repetition_signal_bundle(list(cleaned_paragraphs)) + + +def _safe_repetition_signal_bundle(cleaned_paragraphs: List[str]) -> Dict[str, object]: + # Repetition analysis is one of the expensive pure checks repeatedly invoked + # during quality repair. Return a copy so callers can keep mutating payloads. + return deepcopy(_cached_repetition_signal_bundle(tuple(cleaned_paragraphs))) + + +def extract_latin_token_hits(text: str, *, field: str = "body", precleaned: bool = False) -> List[Dict[str, object]]: + cleaned = str(text or "") if precleaned else _normalize_visible_text_for_language_scan(str(text or "")) + hits: List[Dict[str, object]] = [] + for match in LATIN_TOKEN_PATTERN.finditer(cleaned): + token = match.group(0) + start = max(0, match.start() - 12) + end = min(len(cleaned), match.end() + 12) + hits.append( + { + "field": field, + "token": token, + "allowed": bool(LATIN_TOKEN_WHITELIST_PATTERN.fullmatch(token)), + "context_excerpt": cleaned[start:end], + } + ) + return hits + + def lint_prose(text: str) -> Dict[str, object]: paragraphs = _split_paragraphs(text) cleaned = sanitize_text(style_sanitize(text)) cleaned_paragraphs = _split_paragraphs(cleaned) + repetition_bundle = _safe_repetition_signal_bundle(cleaned_paragraphs) + latin_token_hits = extract_latin_token_hits(text, field="body") dialogue_count = _count_dialogue(cleaned) action_count = _count_actions(cleaned) detail_count = _count_details(cleaned) @@ -47,11 +105,26 @@ def lint_prose(text: str) -> Dict[str, object]: "meta_sentence_rate": meta_rate, "engineering_leak_rate": 0.0 if not detect_meta_leaks(cleaned) else 1.0, "repetition_score": repetition_score(cleaned_paragraphs), + "repetition_signal_bundle": repetition_bundle, + "lexical_repetition_score": repetition_bundle["lexical_repetition_score"], + "paragraph_similarity_score": repetition_bundle["paragraph_similarity_score"], + "semantic_paragraph_similarity_score": repetition_bundle["semantic_paragraph_similarity_score"], + "n_gram_repetition_score": repetition_bundle["n_gram_repetition_score"], + "beat_structure_repetition_score": repetition_bundle["beat_structure_repetition_score"], + "suspicious_refrain_count": repetition_bundle["suspicious_refrain_count"], + "event_coverage_gap_score": repetition_bundle["event_coverage_gap_score"], + "beat_coverage_gap_score": repetition_bundle["beat_coverage_gap_score"], + "uncovered_event_count": repetition_bundle["uncovered_event_count"], + "uncovered_beat_count": repetition_bundle["uncovered_beat_count"], + "overcovered_beat_count": repetition_bundle["overcovered_beat_count"], "exposition_ratio": exposition_ratio, "dialogue_plus_action_ratio": dialogue_plus_action_ratio, "concrete_detail_density": concrete_detail_density, + "latin_token_hits": latin_token_hits, + "disallowed_latin_token_hits": [item for item in latin_token_hits if not item["allowed"]], "dialogue_count": dialogue_count, "action_count": action_count, "detail_count": detail_count, + "text_unit_count": story_text_unit_count(cleaned), "raw_paragraphs": paragraphs, } diff --git a/src/narrativeos/providers.py b/src/narrativeos/providers.py index 8efa587..a6e08ba 100644 --- a/src/narrativeos/providers.py +++ b/src/narrativeos/providers.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from collections import OrderedDict from time import perf_counter -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Dict, List, Optional, Sequence, Set from urllib import error as urlerror from urllib import request as urlrequest @@ -471,6 +471,7 @@ class StaticCandidateProvider(CandidateProvider): def __init__(self, event_pool: Sequence[EventAtom]) -> None: self.event_pool = [EventAtom.from_dict(event.to_dict()) for event in event_pool] + self._seen_cache_keys: Set[str] = set() _CONTINUATION_FUNCTIONS_BY_PHASE: Dict[str, List[str]] = { "setup": ["false_peace", "temptation", "confession_window"], @@ -612,6 +613,79 @@ def _continuation_seeds( } ] + def _continuation_blueprint( + self, + base_event: EventAtom, + *, + scene_function: str, + index: int, + ) -> Optional[Dict[str, Any]]: + blueprints = [ + dict(item) + for item in list((base_event.metadata or {}).get("continuation_blueprints") or []) + if isinstance(item, dict) + ] + if not blueprints: + return None + matching = [ + item + for item in blueprints + if str(item.get("scene_function") or "") == scene_function + ] + if not matching: + return None + return dict(matching[index % len(matching)]) + + def _continuation_base_priority(self, base_event: EventAtom, state: NarrativeState) -> tuple[int, int, int]: + blueprints = [ + dict(item) + for item in list((base_event.metadata or {}).get("continuation_blueprints") or []) + if isinstance(item, dict) + ] + open_promise_ids = {str(promise.promise_id) for promise in state.open_promises} + duty_type = str((state.current_chapter_task or {}).get("duty_type") or "") + closes_open_promise = any( + str(promise_id) in open_promise_ids + for blueprint in blueprints + for promise_id in list(blueprint.get("promises_close") or []) + ) or any(str(promise.promise_id) in open_promise_ids for promise in base_event.promises_open) + phase_matches = any( + not list(blueprint.get("phase_allowlist") or []) + or state.story_phase in {str(item) for item in list(blueprint.get("phase_allowlist") or [])} + for blueprint in blueprints + ) + duty_matches = any( + not list(blueprint.get("duty_allowlist") or []) + or (duty_type and duty_type in {str(item) for item in list(blueprint.get("duty_allowlist") or [])}) + for blueprint in blueprints + ) + return ( + 0 if closes_open_promise else 1, + 0 if phase_matches else 1, + 0 if duty_matches else 1, + ) + + def _continuation_promises_close( + self, + *, + state: NarrativeState, + base_event: EventAtom, + blueprint: Optional[Dict[str, Any]], + ) -> List[str]: + open_promise_ids = {str(promise.promise_id) for promise in state.open_promises} + explicit_close_ids = list(blueprint.get("promises_close") or []) if blueprint else [] + if explicit_close_ids: + return [ + str(promise_id) + for promise_id in explicit_close_ids + if str(promise_id) in open_promise_ids + ] + return [ + str(promise.promise_id) + for promise in base_event.promises_open + if str(promise.promise_id) in open_promise_ids + ] + def _continuation_variant( self, base_event: EventAtom, @@ -623,7 +697,17 @@ def _continuation_variant( ) -> EventAtom: payload = base_event.to_dict() variant_id = f"{base_event.event_id}__continuation__{state.chapter_index + 1}_{index}_{scene_function}" - tags = list(dict.fromkeys(list(base_event.tags) + list((world.creator_controls.theme_targets or [])[:2]) + [scene_function])) + blueprint = self._continuation_blueprint(base_event, scene_function=scene_function, index=index) + blueprint_tags = list(blueprint.get("tags") or []) if blueprint else [] + tags = list( + dict.fromkeys( + list(blueprint_tags) + + list(base_event.tags) + + list((world.creator_controls.theme_targets or [])[:2]) + + [scene_function] + ) + ) + actors = list(blueprint.get("actors") or base_event.actors) if blueprint else list(base_event.actors) metadata = dict(payload.get("metadata", {})) for key in ( "terminal", @@ -639,20 +723,26 @@ def _continuation_variant( "base_event_id": base_event.event_id, "continuation_phase": state.story_phase, "generated_from_static_pool": True, + **({"continuation_blueprint_id": str(blueprint.get("blueprint_id"))} if blueprint and blueprint.get("blueprint_id") else {}), } ) world_locations = list(world.locations or []) - rotated_location = world_locations[index % len(world_locations)] if world_locations else base_event.location + rotated_location = ( + str(blueprint.get("location")) + if blueprint and blueprint.get("location") + else (world_locations[index % len(world_locations)] if world_locations else base_event.location) + ) payload.update( { "event_id": variant_id, - "title": self._continuation_title(scene_function, rotated_location, index=index), - "summary": self._continuation_summary( + "title": str(blueprint.get("title")) if blueprint and blueprint.get("title") else self._continuation_title(scene_function, rotated_location, index=index), + "summary": str(blueprint.get("summary")) if blueprint and blueprint.get("summary") else self._continuation_summary( scene_function=scene_function, location=rotated_location, world=world, tags=tags, ), + "actors": actors, "scene_function": scene_function, "tags": tags, "preconditions_all": [], @@ -663,20 +753,31 @@ def _continuation_variant( state=state, event_id=variant_id, scene_function=scene_function, - actors=base_event.actors, + actors=actors, + ), + "promises_close": self._continuation_promises_close( + state=state, + base_event=base_event, + blueprint=blueprint, ), - "promises_close": [], "rating_ceiling": state.rating_ceiling or world.creator_controls.darkness_ceiling or base_event.rating_ceiling, "tension_delta": self._SCENE_FUNCTION_TENSION.get(scene_function, max(0.08, float(base_event.tension_delta))), "theme_impacts": { theme: 0.06 for theme in list((world.creator_controls.theme_targets or world.themes)[:3]) or list(tags[:2]) }, - "agency_affordances": list(dict.fromkeys(list(base_event.agency_affordances) + list(tags[:2]) + ["continue_story"])), + "agency_affordances": list( + dict.fromkeys( + list(base_event.agency_affordances) + + list(blueprint.get("agency_affordances") or [] if blueprint else []) + + list(tags[:2]) + + ["continue_story"] + ) + ), "karmic_seed_creations": self._continuation_seeds( event_id=variant_id, scene_function=scene_function, - actors=base_event.actors, + actors=actors, tags=tags, ), "karmic_seed_resolutions": [], @@ -700,7 +801,14 @@ def _continuation_candidates( event_ids = set(existing_event_ids) scene_functions = self._continuation_functions(state) variants: List[EventAtom] = [] - for base_index, base_event in enumerate(self.event_pool): + prioritized_pool = [ + base_event + for _original_index, base_event in sorted( + enumerate(self.event_pool), + key=lambda item: (*self._continuation_base_priority(item[1], state), item[0]), + ) + ] + for base_index, base_event in enumerate(prioritized_pool): for function_index, scene_function in enumerate(scene_functions): variant = self._continuation_variant( base_event, @@ -717,6 +825,30 @@ def _continuation_candidates( return variants return variants + def _cache_key( + self, + state: NarrativeState, + world: WorldBible, + *, + depth: int, + min_candidates: int, + max_candidates: int, + ) -> str: + payload = { + "world_id": world.world_id, + "state_id": state.state_id, + "turn_index": state.turn_index, + "chapter_index": state.chapter_index, + "story_phase": state.story_phase, + "visited_event_ids": list(state.visited_event_ids), + "min_end_turn": state.min_end_turn, + "depth": depth, + "min_candidates": min_candidates, + "max_candidates": max_candidates, + "event_pool_ids": [event.event_id for event in self.event_pool], + } + return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()[:16] + def generate( self, state: NarrativeState, @@ -726,6 +858,15 @@ def generate( min_candidates: int = 6, max_candidates: int = 10, ) -> CandidateBatch: + cache_key = self._cache_key( + state, + world, + depth=depth, + min_candidates=min_candidates, + max_candidates=max_candidates, + ) + cache_hit = cache_key in self._seen_cache_keys + self._seen_cache_keys.add(cache_key) raw_candidates = [ EventAtom.from_dict(event.to_dict()) for event in self.event_pool @@ -742,8 +883,12 @@ def generate( legal_candidates.append(candidate) continuation_candidates: List[EventAtom] = [] + longform_mode = bool(state.metadata.get("longform_plan_enabled")) + continuation_mode = "longform" if longform_mode else ( + "long_route" if state.min_end_turn >= self._LONG_ROUTE_CONTINUATION_MIN_END_TURN else "off" + ) if ( - state.min_end_turn >= self._LONG_ROUTE_CONTINUATION_MIN_END_TURN + continuation_mode != "off" and len(legal_candidates) < min_candidates ): continuation_limit = max( @@ -775,6 +920,9 @@ def generate( "raw_count": len(raw_candidates), "legal_count": len(legal_candidates), "min_candidates_requested": min_candidates, + "cache_hit": cache_hit, + "cache_key": cache_key, + "continuation_mode": continuation_mode, "continuation_candidate_count": len(continuation_candidates), }, ) diff --git a/src/narrativeos/quality/__init__.py b/src/narrativeos/quality/__init__.py new file mode 100644 index 0000000..54c9902 --- /dev/null +++ b/src/narrativeos/quality/__init__.py @@ -0,0 +1,60 @@ +from .adapter import ( + build_guardrail_records, + build_phase1_grounding_result, + persist_guardrail_records, + record_publish_preflight_quality_event, +) +from .grounding import build_grounding_check, build_grounding_decision +from .config import ( + QualityConfigError, + QualityConfigPaths, + load_grounding_policies, + get_quality_policy_for_scenario, + load_content_rubrics_config, + load_quality_config_bundle, + load_quality_review_policies, + load_quality_risk_tiers, + load_quality_rules, + load_quality_scenarios, +) +from .models import ( + ContentQualityScore, + GuardrailDecision, + GroundingCheck, + GroundingDecision, + GroundingEvidenceRef, + QualityFeedbackItem, + QualityEvent, + QualityPolicy, + QualityRule, + ReviewCase, +) + +__all__ = [ + "ContentQualityScore", + "GuardrailDecision", + "GroundingCheck", + "GroundingDecision", + "GroundingEvidenceRef", + "QualityFeedbackItem", + "build_guardrail_records", + "build_grounding_check", + "build_grounding_decision", + "build_phase1_grounding_result", + "persist_guardrail_records", + "QualityConfigError", + "QualityConfigPaths", + "get_quality_policy_for_scenario", + "QualityEvent", + "QualityPolicy", + "QualityRule", + "record_publish_preflight_quality_event", + "ReviewCase", + "load_content_rubrics_config", + "load_grounding_policies", + "load_quality_config_bundle", + "load_quality_review_policies", + "load_quality_risk_tiers", + "load_quality_rules", + "load_quality_scenarios", +] diff --git a/src/narrativeos/quality/adapter.py b/src/narrativeos/quality/adapter.py new file mode 100644 index 0000000..488c265 --- /dev/null +++ b/src/narrativeos/quality/adapter.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, Optional +from uuid import uuid4 + +from ..models import EvaluationReport +from .config import get_quality_policy_for_scenario +from .grounding import build_grounding_check +from .models import ContentQualityScore, GroundingCheck, GuardrailDecision, QualityEvent, ReviewCase + + +SCENARIO_CASE_TYPES = { + "reader_continue": "runtime_quality", + "author_generate_chapter": "content_quality", + "author_manual_edit": "content_quality", + "publish_candidate": "publish_quality", +} + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _status_from_quality_gate(quality_gate: Dict[str, Any]) -> str: + if bool(quality_gate.get("ok", False)): + return "passed" + if str(quality_gate.get("enforced_decision") or "") == "block": + return "blocked" + return "review_required" + + +def _coerce_grounding_check(value: Any) -> Optional[GroundingCheck]: + if isinstance(value, GroundingCheck): + return value + if isinstance(value, dict) and value: + return GroundingCheck.from_dict(value) + return None + + +def enforce_grounding_quality_gate( + quality_bundle: Dict[str, Any], + *, + grounding_check: GroundingCheck | Dict[str, Any], + source_surface: str, +) -> Dict[str, Any]: + bundle = dict(quality_bundle or {}) + check = _coerce_grounding_check(grounding_check) + if check is None: + return bundle + quality_gate = dict(bundle.get("quality_gate") or {}) + grounding_payload = check.to_dict() + quality_gate["grounding_status"] = check.status + quality_gate["grounding_result"] = grounding_payload + quality_gate.setdefault("code", "chapter_quality_guard_failed") + if check.status == "failed": + failed_checks = [ + str(item) + for item in list(quality_gate.get("failed_checks") or []) + if str(item) + ] + for reason_code in list(check.reason_codes or []) or ["grounding_missing_support"]: + if reason_code not in failed_checks: + failed_checks.append(reason_code) + quality_gate["ok"] = False + quality_gate["failed_checks"] = failed_checks + quality_gate.setdefault("failed_contract_checks", []) + quality_gate["enforced_decision"] = "block" if str(source_surface or "") == "reader" else "rewrite" + quality_gate["summary"] = str(check.summary or "grounding failed") or "grounding failed" + quality_gate["blocking_dimension"] = "grounding" + bundle["quality_gate"] = quality_gate + bundle["grounding_check"] = check + return bundle + + +def _rule_hits(quality_bundle: Dict[str, Any]) -> list[Dict[str, Any]]: + quality_gate = dict(quality_bundle.get("quality_gate") or {}) + failed_checks = [str(item) for item in list(quality_gate.get("failed_checks") or []) if str(item)] + contract_checks = [str(item) for item in list(quality_gate.get("failed_contract_checks") or []) if str(item)] + if not failed_checks and not contract_checks: + return [{"rule_id": "chapter_quality_gate", "reason_code": "passed", "blocking": False}] + return [ + { + "rule_id": "chapter_quality_gate", + "reason_code": reason_code, + "blocking": str(quality_gate.get("enforced_decision") or "") == "block", + } + for reason_code in failed_checks + contract_checks + ] + + +def _reason_codes(report: EvaluationReport, quality_gate: Dict[str, Any]) -> list[str]: + issue_codes = [str(issue.issue_code) for issue in list(report.issues or []) if str(issue.issue_code or "")] + failed_checks = [str(item) for item in list(quality_gate.get("failed_checks") or []) if str(item)] + if not issue_codes and not failed_checks: + return ["quality_passed"] + ordered = [] + for item in issue_codes + failed_checks: + if item not in ordered: + ordered.append(item) + return ordered + + +def _evidence_refs(report: EvaluationReport, source_ref: Dict[str, Any]) -> list[Dict[str, Any]]: + refs = [] + chapter_id = str(source_ref.get("chapter_id") or report.chapter_id or "") + if chapter_id: + refs.append({"kind": "evaluation_report", "ref_id": chapter_id}) + for issue in list(report.issues or []): + if issue.evidence: + refs.append( + { + "kind": "issue_evidence", + "ref_id": str(issue.issue_code), + "issue_code": str(issue.issue_code), + "preview": " | ".join(str(item) for item in list(issue.evidence or [])[:3]), + } + ) + return refs + + +def build_phase1_grounding_result() -> Dict[str, Any]: + return { + "status": "not_evaluated", + "mode": "observe_only", + "evidence_refs": [], + "missing_support": [], + "contradictions": [], + } + + +def build_guardrail_records( + *, + quality_bundle: Dict[str, Any], + scenario_id: str, + source_surface: str, + source_ref: Dict[str, Any], + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + chapter_id: Optional[str] = None, + coverage_context: Optional[Dict[str, Any]] = None, + state_after: Optional[Any] = None, + worldpack_payload: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + report = quality_bundle.get("report") + if isinstance(report, dict): + report = EvaluationReport.from_dict(report) + assert isinstance(report, EvaluationReport) + policy = get_quality_policy_for_scenario(scenario_id) + grounding_check = _coerce_grounding_check(quality_bundle.get("grounding_check") or quality_bundle.get("grounding_result")) + if grounding_check is None: + grounding_check = build_grounding_check( + scenario_id=scenario_id, + text=str(source_ref.get("rendered_text") or ""), + source_surface=source_surface, + world_version_id=world_version_id, + session_id=session_id, + chapter_id=chapter_id, + coverage_context=coverage_context, + state_after=state_after, + worldpack_payload=worldpack_payload, + ) + quality_bundle = enforce_grounding_quality_gate( + quality_bundle, + grounding_check=grounding_check, + source_surface=source_surface, + ) + quality_gate = dict(quality_bundle.get("quality_gate") or {}) + hard_constraint_result = dict(quality_gate.get("hard_constraint_result") or {}) + status = _status_from_quality_gate(quality_gate) + trace_id = f"quality_trace_{uuid4().hex[:12]}" + score_id = f"quality_score_{uuid4().hex[:12]}" + case_id = f"review_case_{uuid4().hex[:12]}" if status != "passed" else None + reason_codes = _reason_codes(report, quality_gate) + evidence_refs = _evidence_refs(report, source_ref) + score = ContentQualityScore( + score_id=score_id, + rubric_version="content_quality_rubric_v1", + overall_score=float(report.scores.overall_score), + dimension_scores={ + "readability": float(report.scores.readability), + "scene_density": float(report.scores.scene_density), + "character_fidelity": float(report.scores.character_fidelity), + "causal_continuity": float(report.scores.causal_continuity), + "pacing": float(report.scores.pacing), + "choice_distinctness": float(report.scores.choice_distinctness), + "hook_quality": float(report.scores.hook_quality), + "monetize_ready": float(report.scores.monetize_ready), + }, + veto=status == "blocked", + reason_codes=reason_codes, + evidence_refs=evidence_refs, + metadata={ + "source_surface": source_surface, + "status": status, + "hard_constraint_result": hard_constraint_result, + }, + ) + decision = GuardrailDecision( + trace_id=trace_id, + status=status, + scenario_id=scenario_id, + risk_tier=policy.risk_tier, + rule_hits=_rule_hits(quality_bundle), + scores_ref=score_id, + grounding_result=grounding_check.to_dict(), + review_required=status != "passed", + review_case_id=case_id, + metadata={ + "policy_id": policy.policy_id, + "policy_mode": policy.mode, + "hard_constraint_result": hard_constraint_result, + }, + ) + review_case = None + if case_id is not None: + review_case = ReviewCase( + case_id=case_id, + case_type=SCENARIO_CASE_TYPES.get(scenario_id, "content_quality"), + status="open", + owner_id=None, + source_ref=dict(source_ref or {}), + reason_codes=reason_codes, + evidence_refs=evidence_refs, + metadata={"trace_id": trace_id, "source_surface": source_surface, "world_version_id": world_version_id, "session_id": session_id}, + ) + event = QualityEvent( + event_id=f"quality_event_{uuid4().hex[:12]}", + trace_id=trace_id, + event_type="guardrail_decision", + source_surface=source_surface, + source_ref=dict(source_ref or {}), + payload={ + "status": status, + "scenario_id": scenario_id, + "risk_tier": policy.risk_tier, + "policy_id": policy.policy_id, + "scores_ref": score_id, + "review_case_id": case_id, + "rule_hits": decision.rule_hits, + "grounding_result": decision.grounding_result, + "hard_constraint_result": hard_constraint_result, + }, + created_at=_utcnow(), + ) + return { + "policy": policy, + "score": score, + "decision": decision, + "review_case": review_case, + "event": event, + "grounding_check": grounding_check, + "trace_id": trace_id, + } + + +def persist_guardrail_records( + repository: Any, + *, + quality_bundle: Dict[str, Any], + scenario_id: str, + source_surface: str, + source_ref: Dict[str, Any], + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + chapter_id: Optional[str] = None, + coverage_context: Optional[Dict[str, Any]] = None, + state_after: Optional[Any] = None, + worldpack_payload: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + records = build_guardrail_records( + quality_bundle=quality_bundle, + scenario_id=scenario_id, + source_surface=source_surface, + source_ref=source_ref, + world_version_id=world_version_id, + session_id=session_id, + chapter_id=chapter_id, + coverage_context=coverage_context, + state_after=state_after, + worldpack_payload=worldpack_payload, + ) + policy = records["policy"] + score = records["score"] + decision = records["decision"] + review_case = records["review_case"] + event = records["event"] + grounding_check = records["grounding_check"] + + saved_policy = repository.save_quality_policy(policy.to_dict()) + saved_grounding_check = repository.save_grounding_check( + { + **grounding_check.to_dict(), + "trace_id": decision.trace_id, + } + ) + saved_score = repository.save_content_quality_score( + { + **score.to_dict(), + "trace_id": decision.trace_id, + "source_surface": source_surface, + "status": decision.status, + "world_version_id": world_version_id, + "session_id": session_id, + "chapter_id": chapter_id or source_ref.get("chapter_id"), + "score_payload": { + **score.to_dict(), + "policy_id": saved_policy["policy_id"], + "grounding_check_id": saved_grounding_check["grounding_check_id"], + "grounding_status": saved_grounding_check["status"], + "hard_constraint_result": (score.metadata or {}).get("hard_constraint_result", {}), + }, + } + ) + saved_case = None + if review_case is not None: + saved_case = repository.save_review_case( + { + **review_case.to_dict(), + "trace_id": decision.trace_id, + "source_surface": source_surface, + "world_version_id": world_version_id, + "session_id": session_id, + "score_id": saved_score["score_id"], + "case_payload": { + **review_case.to_dict(), + "policy_id": saved_policy["policy_id"], + }, + } + ) + saved_event = repository.save_quality_event( + { + **event.to_dict(), + "status": decision.status, + "world_version_id": world_version_id, + "session_id": session_id, + "payload": { + **event.payload, + "policy_id": saved_policy["policy_id"], + "scores_ref": saved_score["score_id"], + "review_case_id": saved_case["case_id"] if saved_case else None, + "grounding_check_id": saved_grounding_check["grounding_check_id"], + "grounding_status": saved_grounding_check["status"], + "hard_constraint_result": (event.payload or {}).get("hard_constraint_result", {}), + }, + } + ) + return { + "policy": saved_policy, + "grounding_check": saved_grounding_check, + "score": saved_score, + "decision": { + **decision.to_dict(), + "scores_ref": saved_score["score_id"], + "review_case_id": saved_case["case_id"] if saved_case else None, + "grounding_result": saved_grounding_check, + }, + "review_case": saved_case, + "event": saved_event, + "trace_id": decision.trace_id, + } + + +def record_publish_preflight_quality_event( + repository: Any, + *, + world_id: str, + world_version_id: str, + status: str, + reason_codes: list[str], + reviewer_id: Optional[str] = None, +) -> Dict[str, Any]: + policy = get_quality_policy_for_scenario("publish_candidate") + repository.save_quality_policy(policy.to_dict()) + trace_id = f"quality_trace_{uuid4().hex[:12]}" + review_case = None + if status != "passed": + review_case = repository.save_review_case( + { + "case_id": f"review_case_{uuid4().hex[:12]}", + "trace_id": trace_id, + "case_type": "publish_quality", + "status": "open", + "owner_id": reviewer_id, + "source_surface": "publish", + "world_version_id": world_version_id, + "session_id": None, + "score_id": None, + "source_ref": {"kind": "world_version", "world_id": world_id, "world_version_id": world_version_id}, + "reason_codes": reason_codes, + "evidence_refs": [{"kind": "publish_checklist", "ref_id": world_version_id}], + "case_payload": {"policy_id": policy.policy_id, "status": status}, + } + ) + event = repository.save_quality_event( + { + "event_id": f"quality_event_{uuid4().hex[:12]}", + "trace_id": trace_id, + "event_type": "publish_preflight", + "source_surface": "publish", + "status": status, + "world_version_id": world_version_id, + "session_id": None, + "source_ref": {"kind": "world_version", "world_id": world_id, "world_version_id": world_version_id}, + "payload": { + "scenario_id": "publish_candidate", + "risk_tier": policy.risk_tier, + "reason_codes": reason_codes, + "review_case_id": review_case["case_id"] if review_case else None, + "policy_id": policy.policy_id, + "grounding_status": "not_applicable", + }, + } + ) + return { + "trace_id": trace_id, + "event": event, + "review_case": review_case, + } diff --git a/src/narrativeos/quality/config.py b/src/narrativeos/quality/config.py new file mode 100644 index 0000000..2d61afd --- /dev/null +++ b/src/narrativeos/quality/config.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +from .models import QualityPolicy, QualityRule + + +BASE_DIR = Path(__file__).resolve().parents[3] +DEFAULT_QUALITY_CONFIG_DIR = BASE_DIR / "configs" / "quality" + +RISK_TIER_IDS = {"L1", "L2", "L3", "L4"} + + +class QualityConfigError(ValueError): + pass + + +@dataclass(frozen=True) +class QualityConfigPaths: + config_dir: Path = DEFAULT_QUALITY_CONFIG_DIR + + @property + def scenarios(self) -> Path: + return self.config_dir / "scenarios.yaml" + + @property + def risk_tiers(self) -> Path: + return self.config_dir / "risk_tiers.yaml" + + @property + def rules(self) -> Path: + return self.config_dir / "rules.yaml" + + @property + def content_rubrics(self) -> Path: + return self.config_dir / "content_rubrics.yaml" + + @property + def review_policies(self) -> Path: + return self.config_dir / "review_policies.yaml" + + @property + def grounding_policies(self) -> Path: + return self.config_dir / "grounding_policies.yaml" + + +def _load_yaml(path: Path) -> Dict[str, Any]: + try: + payload = yaml.safe_load(path.read_text(encoding="utf-8")) + except OSError as exc: + raise QualityConfigError("quality_config_missing:%s" % path) from exc + if not isinstance(payload, dict): + raise QualityConfigError("quality_config_invalid_root:%s" % path) + return payload + + +def _require_keys(payload: Dict[str, Any], keys: List[str], *, context: str) -> None: + missing = [key for key in keys if key not in payload] + if missing: + raise QualityConfigError("%s_missing_keys:%s" % (context, ",".join(missing))) + + +def load_quality_scenarios(paths: Optional[QualityConfigPaths] = None) -> Dict[str, Any]: + resolved_paths = paths or QualityConfigPaths() + payload = _load_yaml(resolved_paths.scenarios) + _require_keys(payload, ["config_version", "scenarios"], context="quality_scenarios") + scenarios = list(payload.get("scenarios") or []) + scenario_map: Dict[str, Dict[str, Any]] = {} + for item in scenarios: + if not isinstance(item, dict): + raise QualityConfigError("quality_scenarios_invalid_item") + _require_keys( + item, + ["scenario_id", "surface", "description", "default_risk_tier", "quality_policy_id"], + context="quality_scenario", + ) + risk_tier = str(item.get("default_risk_tier") or "") + if risk_tier not in RISK_TIER_IDS: + raise QualityConfigError("quality_scenario_risk_tier_invalid:%s" % risk_tier) + scenario_id = str(item.get("scenario_id") or "") + scenario_map[scenario_id] = dict(item) + return { + "config_version": str(payload.get("config_version") or ""), + "scenarios": list(scenario_map.values()), + "scenario_map": scenario_map, + } + + +def load_quality_risk_tiers(paths: Optional[QualityConfigPaths] = None) -> Dict[str, Any]: + resolved_paths = paths or QualityConfigPaths() + payload = _load_yaml(resolved_paths.risk_tiers) + _require_keys(payload, ["config_version", "risk_tiers"], context="quality_risk_tiers") + tier_map: Dict[str, Dict[str, Any]] = {} + for item in list(payload.get("risk_tiers") or []): + if not isinstance(item, dict): + raise QualityConfigError("quality_risk_tiers_invalid_item") + _require_keys( + item, + ["risk_tier", "label", "description", "requires_human_review", "blocks_on_veto_only"], + context="quality_risk_tier", + ) + tier_id = str(item.get("risk_tier") or "") + if tier_id not in RISK_TIER_IDS: + raise QualityConfigError("quality_risk_tier_invalid:%s" % tier_id) + tier_map[tier_id] = dict(item) + return { + "config_version": str(payload.get("config_version") or ""), + "risk_tiers": list(tier_map.values()), + "risk_tier_map": tier_map, + } + + +def load_quality_rules(paths: Optional[QualityConfigPaths] = None) -> Dict[str, Any]: + resolved_paths = paths or QualityConfigPaths() + payload = _load_yaml(resolved_paths.rules) + _require_keys(payload, ["config_version", "rules"], context="quality_rules") + rules = [QualityRule.from_dict(item) for item in list(payload.get("rules") or [])] + return { + "config_version": str(payload.get("config_version") or ""), + "rules": rules, + "rule_map": {item.rule_id: item for item in rules}, + } + + +def load_content_rubrics_config(paths: Optional[QualityConfigPaths] = None) -> Dict[str, Any]: + resolved_paths = paths or QualityConfigPaths() + payload = _load_yaml(resolved_paths.content_rubrics) + _require_keys(payload, ["config_version", "rubrics"], context="content_rubrics") + rubrics = dict(payload.get("rubrics") or {}) + if "default" not in rubrics: + raise QualityConfigError("content_rubrics_missing_default") + default = dict(rubrics.get("default") or {}) + _require_keys(default, ["rubric_version", "overall_scale", "dimensions", "veto_reason_codes"], context="content_rubric") + return { + "config_version": str(payload.get("config_version") or ""), + "rubrics": rubrics, + } + + +def load_quality_review_policies(paths: Optional[QualityConfigPaths] = None) -> Dict[str, Any]: + resolved_paths = paths or QualityConfigPaths() + payload = _load_yaml(resolved_paths.review_policies) + _require_keys(payload, ["config_version", "policies"], context="quality_review_policies") + policies = [QualityPolicy.from_dict(item) for item in list(payload.get("policies") or [])] + return { + "config_version": str(payload.get("config_version") or ""), + "policies": policies, + "policy_map": {item.policy_id: item for item in policies}, + } + + +def load_quality_config_bundle(paths: Optional[QualityConfigPaths] = None) -> Dict[str, Any]: + resolved_paths = paths or QualityConfigPaths() + scenarios = load_quality_scenarios(resolved_paths) + risk_tiers = load_quality_risk_tiers(resolved_paths) + rules = load_quality_rules(resolved_paths) + rubrics = load_content_rubrics_config(resolved_paths) + policies = load_quality_review_policies(resolved_paths) + + scenario_ids = set(scenarios["scenario_map"].keys()) + rule_ids = set(rules["rule_map"].keys()) + risk_tier_ids = set(risk_tiers["risk_tier_map"].keys()) + + for policy in policies["policies"]: + if policy.scenario_id not in scenario_ids: + raise QualityConfigError("quality_policy_unknown_scenario:%s" % policy.scenario_id) + if policy.risk_tier not in risk_tier_ids: + raise QualityConfigError("quality_policy_unknown_risk_tier:%s" % policy.risk_tier) + missing_rule_ids = [rule_id for rule_id in policy.rule_ids if rule_id not in rule_ids] + if missing_rule_ids: + raise QualityConfigError("quality_policy_unknown_rules:%s" % ",".join(missing_rule_ids)) + + scenario_policy_ids = {item["quality_policy_id"] for item in scenarios["scenarios"]} + missing_policies = sorted(policy_id for policy_id in scenario_policy_ids if policy_id not in policies["policy_map"]) + if missing_policies: + raise QualityConfigError("quality_scenario_missing_policy:%s" % ",".join(missing_policies)) + + return { + "scenarios": scenarios, + "risk_tiers": risk_tiers, + "rules": rules, + "content_rubrics": rubrics, + "review_policies": policies, + } + + +def load_grounding_policies(paths: Optional[QualityConfigPaths] = None) -> Dict[str, Any]: + resolved_paths = paths or QualityConfigPaths() + payload = _load_yaml(resolved_paths.grounding_policies) + _require_keys(payload, ["config_version", "policies"], context="grounding_policies") + return { + "config_version": str(payload.get("config_version") or ""), + "policies": dict(payload.get("policies") or {}), + } + + +def get_quality_policy_for_scenario( + scenario_id: str, + paths: Optional[QualityConfigPaths] = None, +) -> QualityPolicy: + bundle = load_quality_config_bundle(paths) + scenario = dict(bundle["scenarios"]["scenario_map"].get(str(scenario_id) or "") or {}) + if not scenario: + raise QualityConfigError("quality_scenario_unknown:%s" % scenario_id) + policy_id = str(scenario.get("quality_policy_id") or "") + policy = bundle["review_policies"]["policy_map"].get(policy_id) + if policy is None: + raise QualityConfigError("quality_policy_unknown:%s" % policy_id) + return policy diff --git a/src/narrativeos/quality/grounding.py b/src/narrativeos/quality/grounding.py new file mode 100644 index 0000000..8a2643f --- /dev/null +++ b/src/narrativeos/quality/grounding.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import re +from collections import Counter +from typing import Any, Dict, List, Optional, Sequence +from uuid import uuid4 + +from .config import load_grounding_policies +from .models import GroundingCheck, GroundingDecision + + +CLAIM_TOKEN_PATTERN = re.compile(r"[\u4e00-\u9fffA-Za-z0-9_]+") +CJK_PATTERN = re.compile(r"[\u4e00-\u9fff]") +RESULT_MARKERS = ("已经", "终于", "决定", "承认", "发现", "知道", "记得", "答应", "失去", "回到", "继续") +RELATION_MARKERS = ("关系", "誓言", "真相", "债", "命", "世界", "记忆") +CONTRADICTION_PATTERNS = ( + (("全部结束", "已经结束", "结束了"), ("仍未结束", "未结束", "尚未结束", "没有结束")), + (("已经失去", "失去了"), ("仍在", "还在", "没有失去")), + (("已经答应", "答应了"), ("尚未答应", "没有答应", "未答应")), +) + + +def _policy_for_scenario(scenario_id: str) -> Dict[str, Any]: + payload = load_grounding_policies() + return dict((payload.get("policies") or {}).get(str(scenario_id) or "", {}) or {}) + + +def _split_sentences(text: str, pattern: str) -> List[str]: + chunks = [segment.strip() for segment in re.split(pattern, str(text or "")) if segment.strip()] + return chunks + + +def _claim_candidates(text: str, *, split_pattern: str) -> List[str]: + sentences = _split_sentences(text, split_pattern) + claims: List[str] = [] + for sentence in sentences: + if any(marker in sentence for marker in RESULT_MARKERS) or any(marker in sentence for marker in RELATION_MARKERS): + parts = [part.strip() for part in re.split(r"(?:但是|然而|可是|却)", sentence) if part.strip()] + claims.extend(parts or [sentence]) + return claims + + +def _tokenize(text: str) -> List[str]: + tokens: List[str] = [] + for raw in CLAIM_TOKEN_PATTERN.findall(str(text or "")): + token = raw.strip() + if len(token) <= 1: + continue + if CJK_PATTERN.search(token): + if len(token) <= 6: + tokens.append(token) + for size in (3, 4): + if len(token) < size: + continue + tokens.extend(token[index : index + size] for index in range(0, len(token) - size + 1)) + else: + tokens.append(token) + return list(dict.fromkeys(tokens)) + + +def _flatten_evidence_values(value: Any, *, limit: int = 120) -> List[str]: + output: List[str] = [] + if value is None or len(output) >= limit: + return output + if isinstance(value, (str, int, float)): + text = str(value).strip() + return [text] if text else [] + if isinstance(value, dict): + for item in value.values(): + output.extend(_flatten_evidence_values(item, limit=limit - len(output))) + if len(output) >= limit: + break + return output + if isinstance(value, (list, tuple, set)): + for item in value: + output.extend(_flatten_evidence_values(item, limit=limit - len(output))) + if len(output) >= limit: + break + return output + + +def _evidence_pack_tokens( + *, + body: str, + coverage_context: Optional[Dict[str, Any]] = None, + state_after: Optional[Any] = None, + worldpack_payload: Optional[Dict[str, Any]] = None, +) -> Dict[str, str]: + coverage_context = dict(coverage_context or {}) + scene_beats = list(coverage_context.get("scene_beats") or []) + selected_event_ids = [str(item) for item in list(coverage_context.get("selected_event_ids") or []) if str(item)] + chapter_task = dict(coverage_context.get("chapter_task") or {}) + world_facts = list(getattr(state_after, "world_facts", []) or []) + timeline = list(getattr(state_after, "timeline", []) or []) + canonical_memory = list(getattr(state_after, "canonical_memory", []) or []) + active_arc_memory = list(getattr(state_after, "active_arc_memory", []) or []) + rolling_recap = list(getattr(state_after, "rolling_recap", []) or []) + archive_memory = list(getattr(state_after, "archive_memory", []) or []) + open_promises = [getattr(item, "description", "") for item in list(getattr(state_after, "open_promises", []) or [])] + world_bible = dict((worldpack_payload or {}).get("world_bible") or {}) + + evidence_sources = { + "selected_event_ids": " ".join(selected_event_ids), + "scene_beats": " ".join( + " ".join( + str(value) + for value in [ + dict(beat.get("event") or {}).get("title"), + dict(beat.get("event") or {}).get("summary"), + dict(beat.get("event") or {}).get("scene_function"), + dict(beat.get("event") or {}).get("location"), + ] + if str(value or "").strip() + ) + for beat in scene_beats + ), + "chapter_task": " ".join(str(value) for value in chapter_task.values() if isinstance(value, (str, int, float))), + "world_facts": " ".join(str(item) for item in world_facts if str(item)), + "timeline": " ".join(str(item) for item in timeline if str(item)), + "longform_memory": " ".join( + _flatten_evidence_values(canonical_memory) + + _flatten_evidence_values(active_arc_memory) + + _flatten_evidence_values(rolling_recap) + + _flatten_evidence_values(archive_memory) + ), + "open_promises": " ".join(str(item) for item in open_promises if str(item)), + "world_bible": " ".join(str(value) for value in world_bible.values() if isinstance(value, (str, int, float))), + } + return evidence_sources + + +def _supported_token_hits(claim: str, evidence_sources: Dict[str, str]) -> tuple[int, List[Dict[str, Any]]]: + tokens = _tokenize(claim) + refs: List[Dict[str, Any]] = [] + hit_count = 0 + for token in tokens: + for kind, source_text in evidence_sources.items(): + if token and token in source_text: + hit_count += 1 + refs.append({"kind": kind, "ref_id": token, "preview": token}) + break + return hit_count, refs + + +def _contradicts_evidence(claim: str, evidence_sources: Dict[str, str]) -> bool: + evidence_text = " ".join(str(item or "") for item in evidence_sources.values()) + for claim_markers, evidence_markers in CONTRADICTION_PATTERNS: + if any(marker in claim for marker in claim_markers) and any(marker in evidence_text for marker in evidence_markers): + return True + return False + + +def build_grounding_decision( + *, + scenario_id: str, + text: str, + coverage_context: Optional[Dict[str, Any]] = None, + state_after: Optional[Any] = None, + worldpack_payload: Optional[Dict[str, Any]] = None, +) -> GroundingDecision: + policy = _policy_for_scenario(scenario_id) + if not policy: + return GroundingDecision( + status="not_applicable", + confidence=0.0, + evidence_refs=[], + unsupported_claims=[], + reason_codes=[], + summary="no_grounding_policy", + ) + split_pattern = str(policy.get("sentence_split_pattern") or r"[。!?!?]") + min_supported_token_hits = int(policy.get("min_supported_token_hits", 2) or 2) + weak_unsupported_claim_max = int(policy.get("weak_unsupported_claim_max", 1) or 1) + pass_confidence = float(policy.get("min_confidence_for_pass", 0.7) or 0.7) + default_weak_confidence = 0.15 if str(scenario_id or "") == "reader_continue" else 0.4 + weak_confidence = float(policy.get("min_confidence_for_weak", default_weak_confidence) or default_weak_confidence) + + claims = _claim_candidates(text, split_pattern=split_pattern) + if not claims: + return GroundingDecision( + status="not_applicable", + confidence=0.0, + evidence_refs=[], + unsupported_claims=[], + reason_codes=[], + summary="no_grounding_claims_detected", + ) + + evidence_sources = _evidence_pack_tokens( + body=text, + coverage_context=coverage_context, + state_after=state_after, + worldpack_payload=worldpack_payload, + ) + unsupported_claims: List[str] = [] + evidence_refs: List[Dict[str, Any]] = [] + supported_claims = 0 + for claim in claims: + hit_count, refs = _supported_token_hits(claim, evidence_sources) + evidence_refs.extend(refs) + if _contradicts_evidence(claim, evidence_sources): + unsupported_claims.append(claim) + elif hit_count >= min_supported_token_hits: + supported_claims += 1 + else: + unsupported_claims.append(claim) + + confidence = round(supported_claims / float(max(1, len(claims))), 3) + reason_codes: List[str] = [] + if not unsupported_claims and confidence >= pass_confidence: + status = "passed" + elif len(unsupported_claims) <= weak_unsupported_claim_max or confidence >= weak_confidence: + status = "weak" + reason_codes.append("grounding_missing_support") + else: + status = "failed" + reason_codes.append("grounding_missing_support") + + if "但是" in text and unsupported_claims: + if "grounding_contradiction" not in reason_codes: + reason_codes.append("grounding_contradiction") + + summary = f"{status} · claims={len(claims)} · unsupported={len(unsupported_claims)} · confidence={confidence}" + unique_refs = [] + seen = set() + for ref in evidence_refs: + key = (ref.get("kind"), ref.get("ref_id")) + if key in seen: + continue + seen.add(key) + unique_refs.append(ref) + return GroundingDecision( + status=status, + confidence=confidence, + evidence_refs=unique_refs[:12], + unsupported_claims=unsupported_claims[:6], + reason_codes=reason_codes, + summary=summary, + ) + + +def build_grounding_check( + *, + scenario_id: str, + text: str, + source_surface: str, + trace_id: Optional[str] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + chapter_id: Optional[str] = None, + coverage_context: Optional[Dict[str, Any]] = None, + state_after: Optional[Any] = None, + worldpack_payload: Optional[Dict[str, Any]] = None, +) -> GroundingCheck: + decision = build_grounding_decision( + scenario_id=scenario_id, + text=text, + coverage_context=coverage_context, + state_after=state_after, + worldpack_payload=worldpack_payload, + ) + return GroundingCheck( + grounding_check_id=f"grounding_check_{uuid4().hex[:12]}", + trace_id=trace_id, + status=decision.status, + confidence=decision.confidence, + evidence_refs=decision.evidence_refs, + unsupported_claims=decision.unsupported_claims, + reason_codes=decision.reason_codes, + summary=decision.summary, + source_surface=source_surface, + world_version_id=world_version_id, + session_id=session_id, + chapter_id=chapter_id, + ) diff --git a/src/narrativeos/quality/hard_constraints.py b/src/narrativeos/quality/hard_constraints.py new file mode 100644 index 0000000..760d6e0 --- /dev/null +++ b/src/narrativeos/quality/hard_constraints.py @@ -0,0 +1,582 @@ +from __future__ import annotations + +from collections import Counter +from copy import deepcopy +from typing import Any, Dict, List, Optional, Sequence + +from ..content_quality_contracts import ( + DEFAULT_GENERATION_HARD_CONSTRAINTS, + load_content_quality_contracts, +) +from ..long_route_quality import ( + DEFAULT_READER_CHOICE, + STOCK_REFRAIN_REPLACEMENTS, + clean_broken_reader_slots, +) +from ..meta_leak_detector import detect_meta_leaks +from ..prose_linter import story_text_unit_count +from .models import GroundingCheck + + +UNIVERSAL_RULE_IDS = tuple(DEFAULT_GENERATION_HARD_CONSTRAINTS["universal_rules"].keys()) + + +def _deep_copy(payload: Dict[str, Any]) -> Dict[str, Any]: + return deepcopy(dict(payload or {})) + + +def _merge_hard_constraint_config(raw: Dict[str, Any]) -> Dict[str, Any]: + merged = _deep_copy(DEFAULT_GENERATION_HARD_CONSTRAINTS) + payload = dict(raw or {}) + for key, value in payload.items(): + if key in {"universal_rules", "base_thresholds", "genre_profiles", "length_profiles"}: + continue + merged[key] = value + for key in ("universal_rules", "base_thresholds", "genre_profiles", "length_profiles"): + if isinstance(payload.get(key), dict): + base = dict(merged.get(key) or {}) + for child_key, child_value in dict(payload.get(key) or {}).items(): + if isinstance(child_value, dict) and isinstance(base.get(child_key), dict): + base[child_key] = {**dict(base.get(child_key) or {}), **dict(child_value or {})} + else: + base[child_key] = child_value + merged[key] = base + return merged + + +def load_generation_hard_constraints(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + contracts = dict(config or load_content_quality_contracts()) + raw = contracts.get("generation_hard_constraints") + if raw is None and str(contracts.get("config_version") or "").startswith("generation_hard_constraints"): + raw = contracts + return _merge_hard_constraint_config(dict(raw or {})) + + +def _normalize_profile_token(value: str) -> str: + return str(value or "").strip().lower().replace("-", "_").replace(" ", "_") + + +def _worldpack_genre_candidates(worldpack_payload: Optional[Dict[str, Any]]) -> List[str]: + payload = dict(worldpack_payload or {}) + metadata = dict(payload.get("metadata") or {}) + author_brief = dict(metadata.get("author_brief") or {}) + candidates = [ + author_brief.get("genre_preset"), + metadata.get("genre_preset"), + metadata.get("genre"), + payload.get("genre"), + payload.get("world_id"), + ] + style_pack = dict(payload.get("narrative_style_pack") or {}) + candidates.extend([style_pack.get("genre"), style_pack.get("tone")]) + return [_normalize_profile_token(str(item)) for item in candidates if str(item or "").strip()] + + +def _resolve_genre_profile_id( + hard_config: Dict[str, Any], + *, + worldpack_payload: Optional[Dict[str, Any]] = None, + genre_profile: Optional[str] = None, +) -> str: + requested = _normalize_profile_token(str(genre_profile or "")) + candidates = [requested] if requested else _worldpack_genre_candidates(worldpack_payload) + profiles = dict(hard_config.get("genre_profiles") or {}) + alias_map: Dict[str, str] = {} + for profile_id, profile in profiles.items(): + normalized_id = _normalize_profile_token(profile_id) + alias_map[normalized_id] = normalized_id + for alias in list(dict(profile or {}).get("aliases") or []): + alias_map[_normalize_profile_token(str(alias))] = normalized_id + for candidate in candidates: + if candidate in alias_map: + return alias_map[candidate] + for candidate in candidates: + for alias, profile_id in alias_map.items(): + if alias and (alias in candidate or candidate in alias): + return profile_id + return "base" + + +def _resolve_length_profile_id(hard_config: Dict[str, Any], *, target_chapters: int) -> str: + chapter_count = int(target_chapters or 0) + selected = "" + selected_min = -1 + for profile_id, profile in dict(hard_config.get("length_profiles") or {}).items(): + min_chapters = int(dict(profile or {}).get("min_chapters", 0) or 0) + if min_chapters <= chapter_count and min_chapters > selected_min: + selected = str(profile_id) + selected_min = min_chapters + return selected or "short_route" + + +def resolve_generation_hard_constraint_profile( + *, + target_chapters: int = 0, + worldpack_payload: Optional[Dict[str, Any]] = None, + genre_profile: Optional[str] = None, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + hard_config = load_generation_hard_constraints(config) + universal_rules = dict(hard_config.get("universal_rules") or {}) + genre_profile_id = _resolve_genre_profile_id(hard_config, worldpack_payload=worldpack_payload, genre_profile=genre_profile) + length_profile_id = _resolve_length_profile_id(hard_config, target_chapters=target_chapters) + genre_payload = dict((hard_config.get("genre_profiles") or {}).get(genre_profile_id) or {}) + length_payload = dict((hard_config.get("length_profiles") or {}).get(length_profile_id) or {}) + + thresholds = dict(hard_config.get("base_thresholds") or {}) + thresholds.update(dict(genre_payload.get("threshold_overrides") or {})) + thresholds.update(dict(length_payload.get("threshold_overrides") or {})) + + disabled_rules = {str(item) for item in list(genre_payload.get("disabled_rules") or []) if str(item)} + disabled_rules.update(str(item) for item in list(length_payload.get("disabled_rules") or []) if str(item)) + profile_warnings = [] + ignored_universal_disables = [rule_id for rule_id in UNIVERSAL_RULE_IDS if rule_id in disabled_rules] + if ignored_universal_disables: + profile_warnings.append( + { + "code": "universal_rules_cannot_be_disabled", + "rule_ids": ignored_universal_disables, + } + ) + active_rules = [rule_id for rule_id in UNIVERSAL_RULE_IDS if rule_id in universal_rules] + for rule_id in list(genre_payload.get("enabled_rules") or []) + list(length_payload.get("enabled_rules") or []): + normalized = str(rule_id or "").strip() + if normalized and normalized not in active_rules and normalized not in disabled_rules: + active_rules.append(normalized) + + return { + "config_version": str(hard_config.get("config_version") or ""), + "profile_id": "%s:%s" % (genre_profile_id, length_profile_id), + "genre_profile": genre_profile_id, + "length_profile": length_profile_id, + "repair_policy": str(hard_config.get("repair_policy") or "repair_once_then_fail_closed"), + "universal_rules": universal_rules, + "active_rules": active_rules, + "thresholds": thresholds, + "profile_warnings": profile_warnings, + } + + +def build_generation_hard_constraint_prompt_contract( + *, + target_chapters: int = 0, + worldpack_payload: Optional[Dict[str, Any]] = None, + genre_profile: Optional[str] = None, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + profile = resolve_generation_hard_constraint_profile( + target_chapters=target_chapters, + worldpack_payload=worldpack_payload, + genre_profile=genre_profile, + config=config, + ) + universal_rules = dict(profile.get("universal_rules") or {}) + return { + "config_version": profile.get("config_version"), + "profile_id": profile.get("profile_id"), + "repair_policy": profile.get("repair_policy"), + "hard_rules": [ + { + "rule_id": rule_id, + "issue_code": dict(universal_rules.get(rule_id) or {}).get("issue_code"), + "action": dict(universal_rules.get(rule_id) or {}).get("action"), + "summary": dict(universal_rules.get(rule_id) or {}).get("summary"), + } + for rule_id in list(profile.get("active_rules") or []) + if rule_id in universal_rules + ], + "thresholds": dict(profile.get("thresholds") or {}), + } + + +def _coerce_grounding_status(grounding_check: Any, quality_gate: Optional[Dict[str, Any]] = None) -> str: + if isinstance(grounding_check, GroundingCheck): + return str(grounding_check.status or "") + if isinstance(grounding_check, dict): + return str(grounding_check.get("status") or "") + return str(dict(quality_gate or {}).get("grounding_status") or "") + + +def _reader_field_texts(reader_view: Dict[str, Any]) -> Dict[str, str]: + payload = dict(reader_view or {}) + fields = { + "chapter_title": str(payload.get("chapter_title") or ""), + "recap": str(payload.get("recap") or ""), + "body": str(payload.get("body") or ""), + } + scene_card = dict(payload.get("scene_card") or {}) + for key in ("title", "summary", "quote", "pull_quote"): + if key in scene_card: + fields[f"scene_card.{key}"] = str(scene_card.get(key) or "") + for key in ("story_beats", "beats", "visual_details"): + for index, item in enumerate(list(scene_card.get(key) or []), start=1): + fields[f"scene_card.{key}[{index}]"] = str(item or "") + for index, item in enumerate(list(payload.get("relationship_hints") or []), start=1): + fields[f"relationship_hints[{index}]"] = str(item or "") + for index, item in enumerate(list(payload.get("choices") or []), start=1): + fields[f"choices[{index}]"] = str(item or "") + return fields + + +def _append_violation( + violations: List[Dict[str, Any]], + *, + rule_id: str, + issue_code: str, + field: str = "", + evidence: Optional[Dict[str, Any]] = None, +) -> None: + violations.append( + { + "rule_id": rule_id, + "issue_code": issue_code, + "field": field, + "evidence": dict(evidence or {}), + } + ) + + +def _meta_hit_groups(text: str) -> Dict[str, List[str]]: + hits = detect_meta_leaks(text) + engineering = [hit for hit in hits if "_" in hit or "->" in hit or "event_id" in hit or "seed_id" in hit] + meta = [hit for hit in hits if hit not in engineering] + return {"engineering": engineering, "meta": meta} + + +def evaluate_reader_generation_hard_constraints( + *, + reader_view: Dict[str, Any], + quality_gate: Optional[Dict[str, Any]] = None, + grounding_check: Any = None, + target_chapters: int = 0, + worldpack_payload: Optional[Dict[str, Any]] = None, + genre_profile: Optional[str] = None, + repair_report: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + profile = resolve_generation_hard_constraint_profile( + target_chapters=target_chapters, + worldpack_payload=worldpack_payload, + genre_profile=genre_profile, + config=config, + ) + universal_rules = dict(profile.get("universal_rules") or {}) + thresholds = dict(profile.get("thresholds") or {}) + active_rules = set(str(item) for item in list(profile.get("active_rules") or []) if str(item)) + payload = dict(reader_view or {}) + quality_gate_payload = dict(quality_gate or {}) + violations: List[Dict[str, Any]] = [] + choices = [str(item or "") for item in list(payload.get("choices") or [])] + non_empty_choices = [item for item in choices if item.strip()] + + if "schema_complete" in active_rules: + min_choice_count = int(thresholds.get("min_choice_count", 2) or 2) + min_body_text_units = int(thresholds.get("min_body_text_units", 80) or 80) + if not str(payload.get("chapter_title") or "").strip(): + _append_violation(violations, rule_id="schema_complete", issue_code="Q10", field="chapter_title") + body = str(payload.get("body") or "") + if story_text_unit_count(body) < min_body_text_units: + _append_violation( + violations, + rule_id="schema_complete", + issue_code="Q10", + field="body", + evidence={"text_units": story_text_unit_count(body), "minimum": min_body_text_units}, + ) + if len(non_empty_choices) < min_choice_count: + _append_violation( + violations, + rule_id="schema_complete", + issue_code="Q10", + field="choices", + evidence={"choice_count": len(non_empty_choices), "minimum": min_choice_count}, + ) + for index, choice in enumerate(choices, start=1): + if not choice.strip(): + _append_violation(violations, rule_id="schema_complete", issue_code="Q10", field=f"choices[{index}]") + + field_texts = _reader_field_texts(payload) + if "broken_slot" in active_rules: + for field, text in field_texts.items(): + cleaned, report = clean_broken_reader_slots(text) + if cleaned != text.strip() or report.get("broken_slot_repaired"): + _append_violation( + violations, + rule_id="broken_slot", + issue_code="Q10", + field=field, + evidence={"repairs": list(report.get("broken_slot_repairs") or [])}, + ) + + if "engineering_leak" in active_rules or "meta_narration_leak" in active_rules: + for field, text in field_texts.items(): + grouped = _meta_hit_groups(text) + if "engineering_leak" in active_rules and grouped["engineering"]: + _append_violation( + violations, + rule_id="engineering_leak", + issue_code="Q01", + field=field, + evidence={"patterns": grouped["engineering"]}, + ) + if "meta_narration_leak" in active_rules and grouped["meta"]: + _append_violation( + violations, + rule_id="meta_narration_leak", + issue_code="Q02", + field=field, + evidence={"patterns": grouped["meta"]}, + ) + + grounding_status = _coerce_grounding_status(grounding_check, quality_gate_payload) + if "grounding_failed" in active_rules and grounding_status == "failed": + _append_violation( + violations, + rule_id="grounding_failed", + issue_code="Q07", + field="grounding", + evidence={"grounding_status": grounding_status}, + ) + + failed_checks = {str(item) for item in list(quality_gate_payload.get("failed_checks") or []) if str(item)} + failed_checks.update(str(item) for item in list(quality_gate_payload.get("failed_contract_checks") or []) if str(item)) + if "premature_terminal" in active_rules and failed_checks & {"q09_pre_end", "premature_terminal_forbidden"}: + _append_violation( + violations, + rule_id="premature_terminal", + issue_code="Q09", + field="quality_gate", + evidence={"failed_checks": sorted(failed_checks & {"q09_pre_end", "premature_terminal_forbidden"})}, + ) + + joined_text = "\n".join(field_texts.values()) + if "stock_refrain_budget" in active_rules: + max_current = int(thresholds.get("stock_refrain_current_max", 2) or 2) + for phrase in STOCK_REFRAIN_REPLACEMENTS: + if phrase == DEFAULT_READER_CHOICE: + continue + count = joined_text.count(phrase) + if count > max_current: + _append_violation( + violations, + rule_id="stock_refrain_budget", + issue_code="Q03", + field="reader_view", + evidence={"phrase": phrase, "count": count, "maximum": max_current}, + ) + + if "choice_text_budget" in active_rules: + max_choice_occurrences = int(thresholds.get("choice_text_current_max", 1) or 1) + counts = Counter("".join(str(choice).split()) for choice in non_empty_choices) + default_choice = "".join(DEFAULT_READER_CHOICE.split()) + for normalized_choice, count in counts.items(): + if not normalized_choice: + continue + if count > max_choice_occurrences or normalized_choice == default_choice: + _append_violation( + violations, + rule_id="choice_text_budget", + issue_code="Q08", + field="choices", + evidence={"choice": normalized_choice, "count": count, "maximum": max_choice_occurrences}, + ) + + unique_failed_checks = list(dict.fromkeys(str(item.get("rule_id") or "") for item in violations if str(item.get("rule_id") or ""))) + repair_actions = list(dict(repair_report or {}).get("actions") or []) + repair_attempts = 1 if repair_actions else 0 + return { + "ok": not violations, + "config_version": profile.get("config_version"), + "profile_id": profile.get("profile_id"), + "genre_profile": profile.get("genre_profile"), + "length_profile": profile.get("length_profile"), + "repair_policy": profile.get("repair_policy"), + "repair_attempts": repair_attempts, + "repair_applied": bool(repair_actions), + "repair_success": bool(repair_actions) and not violations, + "failed_checks": unique_failed_checks, + "violations": violations, + "thresholds": thresholds, + "profile_warnings": list(profile.get("profile_warnings") or []), + "active_rules": list(profile.get("active_rules") or []), + } + + +def enforce_generation_hard_constraints( + quality_bundle: Dict[str, Any], + *, + reader_view: Dict[str, Any], + grounding_check: Any = None, + source_surface: str, + target_chapters: int = 0, + worldpack_payload: Optional[Dict[str, Any]] = None, + repair_report: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + bundle = dict(quality_bundle or {}) + quality_gate = dict(bundle.get("quality_gate") or {}) + result = evaluate_reader_generation_hard_constraints( + reader_view=reader_view, + quality_gate=quality_gate, + grounding_check=grounding_check or bundle.get("grounding_check") or bundle.get("grounding_result"), + target_chapters=target_chapters, + worldpack_payload=worldpack_payload, + repair_report=repair_report, + ) + quality_gate["hard_constraint_result"] = result + quality_gate.setdefault("code", "chapter_quality_guard_failed") + if not result.get("ok", True): + existing_failed = [str(item) for item in list(quality_gate.get("failed_checks") or []) if str(item)] + for failed_check in list(result.get("failed_checks") or []): + if failed_check not in existing_failed: + existing_failed.append(str(failed_check)) + quality_gate["failed_checks"] = existing_failed + quality_gate["ok"] = False + quality_gate["enforced_decision"] = "block" + quality_gate["summary"] = "章节未通过生成硬约束:%s" % " / ".join(existing_failed[:4]) + quality_gate["blocking_dimension"] = "hard_constraints" + quality_gate["source_surface"] = str(source_surface or "") + bundle["quality_gate"] = quality_gate + return bundle + + +def summarize_generation_hard_constraints( + chapter_report_payloads: Sequence[Dict[str, Any]], + *, + chapter_trace_payloads: Optional[Sequence[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + reports = [dict(item or {}) for item in list(chapter_report_payloads or [])] + traces = [dict(item or {}) for item in list(chapter_trace_payloads or [])] + violation_counts: Counter[str] = Counter() + field_violation_counts: Counter[str] = Counter() + scene_card_rule_counts: Counter[str] = Counter() + scene_card_issue_counts: Counter[str] = Counter() + hard_fail_count = 0 + repair_attempt_count = 0 + repair_success_count = 0 + for payload in reports: + quality_gate = dict(payload.get("quality_gate") or {}) + result = dict(quality_gate.get("hard_constraint_result") or payload.get("hard_constraint_result") or {}) + if result: + repair_attempt_count += int(result.get("repair_attempts", 0) or 0) + if result.get("repair_success"): + repair_success_count += 1 + if not result.get("ok", True): + hard_fail_count += 1 + for rule_id in list(result.get("failed_checks") or []): + violation_counts[str(rule_id)] += 1 + for violation in list(result.get("violations") or []): + field = str(dict(violation or {}).get("field") or "") + rule_id = str(dict(violation or {}).get("rule_id") or "") + issue_code = str(dict(violation or {}).get("issue_code") or "") + if field: + field_violation_counts[field] += 1 + if field.startswith("scene_card."): + if rule_id: + scene_card_rule_counts[rule_id] += 1 + if issue_code: + scene_card_issue_counts[issue_code] += 1 + continue + hard = dict(payload.get("hard_validator_results") or {}) + decision = dict(payload.get("decision") or {}) + issues = [dict(item or {}) for item in list(payload.get("issues") or [])] + hard_issue_codes = { + str(item.get("issue_code") or "") + for item in issues + if str(item.get("severity") or "") == "high" and str(item.get("issue_code") or "") in {"Q01", "Q02", "Q09", "Q10"} + } + if bool(hard.get("failed")) or str(decision.get("reason") or "") == "hard_validator_failed" or hard_issue_codes: + hard_fail_count += 1 + for issue_code in hard_issue_codes or {"hard_validator_failed"}: + violation_counts[str(issue_code)] += 1 + for trace in traces: + actions = list(trace.get("quality_pass_actions") or []) + if actions: + repair_attempt_count += 1 + if str(dict(trace.get("evaluation") or {}).get("decision") or "") == "pass": + repair_success_count += 1 + chapter_count = len(reports) + return { + "schema_version": "generation_hard_constraint_summary/v1", + "chapter_count": chapter_count, + "hard_fail_count": hard_fail_count, + "hard_fail_rate": round(hard_fail_count / float(max(1, chapter_count)), 3), + "repair_attempt_count": repair_attempt_count, + "repair_success_count": repair_success_count, + "repair_success_rate": round(repair_success_count / float(max(1, repair_attempt_count)), 3), + "violation_mix": [ + {"rule_id": rule_id, "count": count, "share": round(count / float(max(1, hard_fail_count)), 3)} + for rule_id, count in sorted(violation_counts.items(), key=lambda item: (-item[1], item[0])) + ], + "field_violation_mix": [ + {"field": field, "count": count, "share": round(count / float(max(1, hard_fail_count)), 3)} + for field, count in sorted(field_violation_counts.items(), key=lambda item: (-item[1], item[0])) + ], + "scene_card_visible_text_audit": { + "schema_version": "scene_card_visible_text_audit/v1", + "violation_count": sum(scene_card_rule_counts.values()), + "failed_rule_mix": [ + {"rule_id": rule_id, "count": count} + for rule_id, count in sorted(scene_card_rule_counts.items(), key=lambda item: (-item[1], item[0])) + ], + "issue_mix": [ + {"issue_code": issue_code, "count": count} + for issue_code, count in sorted(scene_card_issue_counts.items(), key=lambda item: (-item[1], item[0])) + ], + }, + } + + +def aggregate_generation_hard_constraint_summaries(worlds: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + summaries = [dict(world.get("generation_hard_constraint_summary") or {}) for world in list(worlds or [])] + chapter_count = sum(int(item.get("chapter_count", 0) or 0) for item in summaries) + hard_fail_count = sum(int(item.get("hard_fail_count", 0) or 0) for item in summaries) + repair_attempt_count = sum(int(item.get("repair_attempt_count", 0) or 0) for item in summaries) + repair_success_count = sum(int(item.get("repair_success_count", 0) or 0) for item in summaries) + violation_counts: Counter[str] = Counter() + field_violation_counts: Counter[str] = Counter() + scene_card_rule_counts: Counter[str] = Counter() + scene_card_issue_counts: Counter[str] = Counter() + for summary in summaries: + for item in list(summary.get("violation_mix") or []): + violation_counts[str(item.get("rule_id") or "")] += int(item.get("count", 0) or 0) + for item in list(summary.get("field_violation_mix") or []): + field_violation_counts[str(item.get("field") or "")] += int(item.get("count", 0) or 0) + audit = dict(summary.get("scene_card_visible_text_audit") or {}) + for item in list(audit.get("failed_rule_mix") or []): + scene_card_rule_counts[str(item.get("rule_id") or "")] += int(item.get("count", 0) or 0) + for item in list(audit.get("issue_mix") or []): + scene_card_issue_counts[str(item.get("issue_code") or "")] += int(item.get("count", 0) or 0) + return { + "schema_version": "generation_hard_constraint_summary/v1", + "world_count": len(summaries), + "chapter_count": chapter_count, + "hard_fail_count": hard_fail_count, + "hard_fail_rate": round(hard_fail_count / float(max(1, chapter_count)), 3), + "repair_attempt_count": repair_attempt_count, + "repair_success_count": repair_success_count, + "repair_success_rate": round(repair_success_count / float(max(1, repair_attempt_count)), 3), + "violation_mix": [ + {"rule_id": rule_id, "count": count, "share": round(count / float(max(1, hard_fail_count)), 3)} + for rule_id, count in sorted(violation_counts.items(), key=lambda item: (-item[1], item[0])) + if rule_id + ], + "field_violation_mix": [ + {"field": field, "count": count, "share": round(count / float(max(1, hard_fail_count)), 3)} + for field, count in sorted(field_violation_counts.items(), key=lambda item: (-item[1], item[0])) + if field + ], + "scene_card_visible_text_audit": { + "schema_version": "scene_card_visible_text_audit/v1", + "violation_count": sum(scene_card_rule_counts.values()), + "failed_rule_mix": [ + {"rule_id": rule_id, "count": count} + for rule_id, count in sorted(scene_card_rule_counts.items(), key=lambda item: (-item[1], item[0])) + if rule_id + ], + "issue_mix": [ + {"issue_code": issue_code, "count": count} + for issue_code, count in sorted(scene_card_issue_counts.items(), key=lambda item: (-item[1], item[0])) + if issue_code + ], + }, + } diff --git a/src/narrativeos/quality/models.py b/src/narrativeos/quality/models.py new file mode 100644 index 0000000..5831ac9 --- /dev/null +++ b/src/narrativeos/quality/models.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Optional + + +POLICY_MODES = {"disabled", "observe", "shadow", "enforce"} +RULE_TYPES = {"validator", "evaluator", "grounding", "review_routing"} +SEVERITY_LEVELS = {"low", "medium", "high", "critical"} +GUARDRAIL_STATUSES = {"passed", "blocked", "review_required"} +REVIEW_CASE_STATUSES = {"open", "in_review", "resolved", "dismissed"} +REVIEW_CASE_TYPES = {"content_quality", "runtime_quality", "publish_quality", "campaign_activation"} +FEEDBACK_SIGNALS = {"retry", "negative_proxy", "positive_proxy", "payment_recovery"} +GROUNDING_STATUSES = {"passed", "weak", "failed", "not_applicable"} + + +def _validate_enum(value: str, *, name: str, allowed: set[str]) -> str: + normalized = str(value or "").strip() + if normalized not in allowed: + raise ValueError("%s_invalid:%s" % (name, normalized or "")) + return normalized + + +def _deepcopy(instance: Any) -> Dict[str, Any]: + return asdict(instance) + + +@dataclass +class QualityPolicy: + policy_id: str + version: str + scenario_id: str + risk_tier: str + rule_ids: List[str] + mode: str + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.mode = _validate_enum(self.mode, name="quality_policy_mode", allowed=POLICY_MODES) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "QualityPolicy": + payload = dict(data or {}) + return cls( + policy_id=str(payload.get("policy_id") or ""), + version=str(payload.get("version") or ""), + scenario_id=str(payload.get("scenario_id") or ""), + risk_tier=str(payload.get("risk_tier") or ""), + rule_ids=[str(item) for item in list(payload.get("rule_ids") or []) if str(item)], + mode=str(payload.get("mode") or ""), + metadata=dict(payload.get("metadata") or {}), + ) + + def to_dict(self) -> Dict[str, Any]: + return _deepcopy(self) + + +@dataclass +class QualityRule: + rule_id: str + rule_type: str + severity: str + blocking: bool + config_ref: str + reason_code: str + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.rule_type = _validate_enum(self.rule_type, name="quality_rule_type", allowed=RULE_TYPES) + self.severity = _validate_enum(self.severity, name="quality_rule_severity", allowed=SEVERITY_LEVELS) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "QualityRule": + payload = dict(data or {}) + return cls( + rule_id=str(payload.get("rule_id") or ""), + rule_type=str(payload.get("rule_type") or ""), + severity=str(payload.get("severity") or ""), + blocking=bool(payload.get("blocking", False)), + config_ref=str(payload.get("config_ref") or ""), + reason_code=str(payload.get("reason_code") or ""), + metadata=dict(payload.get("metadata") or {}), + ) + + def to_dict(self) -> Dict[str, Any]: + return _deepcopy(self) + + +@dataclass +class GuardrailDecision: + trace_id: str + status: str + scenario_id: str + risk_tier: str + rule_hits: List[Dict[str, Any]] + scores_ref: Optional[str] = None + grounding_result: Dict[str, Any] = field(default_factory=dict) + review_required: bool = False + review_case_id: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.status = _validate_enum(self.status, name="guardrail_status", allowed=GUARDRAIL_STATUSES) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GuardrailDecision": + payload = dict(data or {}) + return cls( + trace_id=str(payload.get("trace_id") or ""), + status=str(payload.get("status") or ""), + scenario_id=str(payload.get("scenario_id") or ""), + risk_tier=str(payload.get("risk_tier") or ""), + rule_hits=[dict(item or {}) for item in list(payload.get("rule_hits") or [])], + scores_ref=str(payload.get("scores_ref")) if payload.get("scores_ref") is not None else None, + grounding_result=dict(payload.get("grounding_result") or {}), + review_required=bool(payload.get("review_required", False)), + review_case_id=str(payload.get("review_case_id")) if payload.get("review_case_id") is not None else None, + metadata=dict(payload.get("metadata") or {}), + ) + + def to_dict(self) -> Dict[str, Any]: + return _deepcopy(self) + + +@dataclass +class ContentQualityScore: + score_id: str + rubric_version: str + overall_score: float + dimension_scores: Dict[str, Any] + veto: bool + reason_codes: List[str] + evidence_refs: List[Dict[str, Any]] + metadata: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ContentQualityScore": + payload = dict(data or {}) + return cls( + score_id=str(payload.get("score_id") or ""), + rubric_version=str(payload.get("rubric_version") or ""), + overall_score=float(payload.get("overall_score", 0.0) or 0.0), + dimension_scores=dict(payload.get("dimension_scores") or {}), + veto=bool(payload.get("veto", False)), + reason_codes=[str(item) for item in list(payload.get("reason_codes") or []) if str(item)], + evidence_refs=[dict(item or {}) for item in list(payload.get("evidence_refs") or [])], + metadata=dict(payload.get("metadata") or {}), + ) + + def to_dict(self) -> Dict[str, Any]: + return _deepcopy(self) + + +@dataclass +class ReviewCase: + case_id: str + case_type: str + status: str + owner_id: Optional[str] + source_ref: Dict[str, Any] + reason_codes: List[str] + evidence_refs: List[Dict[str, Any]] + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.case_type = _validate_enum(self.case_type, name="review_case_type", allowed=REVIEW_CASE_TYPES) + self.status = _validate_enum(self.status, name="review_case_status", allowed=REVIEW_CASE_STATUSES) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ReviewCase": + payload = dict(data or {}) + owner_id = payload.get("owner_id") + return cls( + case_id=str(payload.get("case_id") or ""), + case_type=str(payload.get("case_type") or ""), + status=str(payload.get("status") or ""), + owner_id=str(owner_id) if owner_id is not None else None, + source_ref=dict(payload.get("source_ref") or {}), + reason_codes=[str(item) for item in list(payload.get("reason_codes") or []) if str(item)], + evidence_refs=[dict(item or {}) for item in list(payload.get("evidence_refs") or [])], + metadata=dict(payload.get("metadata") or {}), + ) + + def to_dict(self) -> Dict[str, Any]: + return _deepcopy(self) + + +@dataclass +class QualityEvent: + event_id: str + trace_id: str + event_type: str + source_surface: str + source_ref: Dict[str, Any] + payload: Dict[str, Any] + created_at: str + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "QualityEvent": + payload = dict(data or {}) + return cls( + event_id=str(payload.get("event_id") or ""), + trace_id=str(payload.get("trace_id") or ""), + event_type=str(payload.get("event_type") or ""), + source_surface=str(payload.get("source_surface") or ""), + source_ref=dict(payload.get("source_ref") or {}), + payload=dict(payload.get("payload") or {}), + created_at=str(payload.get("created_at") or ""), + ) + + def to_dict(self) -> Dict[str, Any]: + return _deepcopy(self) + + +@dataclass +class QualityFeedbackItem: + feedback_item_id: str + feedback_type: str + signal: str + source_surface: str + source_ref: Dict[str, Any] + payload: Dict[str, Any] + created_at: str + trace_id: Optional[str] = None + account_id: Optional[str] = None + world_version_id: Optional[str] = None + session_id: Optional[str] = None + chapter_id: Optional[str] = None + source_event_id: Optional[str] = None + + def __post_init__(self) -> None: + self.signal = _validate_enum(self.signal, name="quality_feedback_signal", allowed=FEEDBACK_SIGNALS) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "QualityFeedbackItem": + payload = dict(data or {}) + return cls( + feedback_item_id=str(payload.get("feedback_item_id") or ""), + feedback_type=str(payload.get("feedback_type") or ""), + signal=str(payload.get("signal") or ""), + source_surface=str(payload.get("source_surface") or ""), + source_ref=dict(payload.get("source_ref") or {}), + payload=dict(payload.get("payload") or {}), + created_at=str(payload.get("created_at") or ""), + trace_id=str(payload.get("trace_id")) if payload.get("trace_id") is not None else None, + account_id=str(payload.get("account_id")) if payload.get("account_id") is not None else None, + world_version_id=str(payload.get("world_version_id")) if payload.get("world_version_id") is not None else None, + session_id=str(payload.get("session_id")) if payload.get("session_id") is not None else None, + chapter_id=str(payload.get("chapter_id")) if payload.get("chapter_id") is not None else None, + source_event_id=str(payload.get("source_event_id")) if payload.get("source_event_id") is not None else None, + ) + + def to_dict(self) -> Dict[str, Any]: + return _deepcopy(self) + + +@dataclass +class GroundingEvidenceRef: + kind: str + ref_id: str + preview: str = "" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GroundingEvidenceRef": + payload = dict(data or {}) + return cls( + kind=str(payload.get("kind") or ""), + ref_id=str(payload.get("ref_id") or ""), + preview=str(payload.get("preview") or ""), + ) + + def to_dict(self) -> Dict[str, Any]: + return _deepcopy(self) + + +@dataclass +class GroundingCheck: + grounding_check_id: str + trace_id: Optional[str] + status: str + confidence: float + evidence_refs: List[Dict[str, Any]] + unsupported_claims: List[str] + reason_codes: List[str] + summary: str + source_surface: str + world_version_id: Optional[str] = None + session_id: Optional[str] = None + chapter_id: Optional[str] = None + created_at: str = "" + + def __post_init__(self) -> None: + self.status = _validate_enum(self.status, name="grounding_status", allowed=GROUNDING_STATUSES) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GroundingCheck": + payload = dict(data or {}) + return cls( + grounding_check_id=str(payload.get("grounding_check_id") or ""), + trace_id=str(payload.get("trace_id")) if payload.get("trace_id") is not None else None, + status=str(payload.get("status") or ""), + confidence=float(payload.get("confidence", 0.0) or 0.0), + evidence_refs=[dict(item or {}) for item in list(payload.get("evidence_refs") or [])], + unsupported_claims=[str(item) for item in list(payload.get("unsupported_claims") or []) if str(item)], + reason_codes=[str(item) for item in list(payload.get("reason_codes") or []) if str(item)], + summary=str(payload.get("summary") or ""), + source_surface=str(payload.get("source_surface") or ""), + world_version_id=str(payload.get("world_version_id")) if payload.get("world_version_id") is not None else None, + session_id=str(payload.get("session_id")) if payload.get("session_id") is not None else None, + chapter_id=str(payload.get("chapter_id")) if payload.get("chapter_id") is not None else None, + created_at=str(payload.get("created_at") or ""), + ) + + def to_dict(self) -> Dict[str, Any]: + return _deepcopy(self) + + +@dataclass +class GroundingDecision: + status: str + confidence: float + evidence_refs: List[Dict[str, Any]] + unsupported_claims: List[str] + reason_codes: List[str] + summary: str + + def __post_init__(self) -> None: + self.status = _validate_enum(self.status, name="grounding_status", allowed=GROUNDING_STATUSES) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GroundingDecision": + payload = dict(data or {}) + return cls( + status=str(payload.get("status") or ""), + confidence=float(payload.get("confidence", 0.0) or 0.0), + evidence_refs=[dict(item or {}) for item in list(payload.get("evidence_refs") or [])], + unsupported_claims=[str(item) for item in list(payload.get("unsupported_claims") or []) if str(item)], + reason_codes=[str(item) for item in list(payload.get("reason_codes") or []) if str(item)], + summary=str(payload.get("summary") or ""), + ) + + def to_dict(self) -> Dict[str, Any]: + return _deepcopy(self) diff --git a/src/narrativeos/rendering.py b/src/narrativeos/rendering.py index 7e612e0..e8aa17b 100644 --- a/src/narrativeos/rendering.py +++ b/src/narrativeos/rendering.py @@ -1,10 +1,12 @@ from __future__ import annotations +import re from abc import ABC, abstractmethod +from time import perf_counter from typing import Any, Dict, List from .core.contracts import style_pack_from_world -from .core.linter import lint_chapter_draft +from .core.linter import lint_chapter_draft, story_text_unit_count from .core.writer import build_scene_plan, write_chapter_draft from .models import ChapterPlan, EventAtom, NarrativeState, RenderedScene, SceneBeat, SceneIntent, SceneRenderSpec, WorldBible from .prompts import get_prompt_text, render_scene_user_prompt @@ -26,6 +28,192 @@ "xianxia": "修行与誓愿", } +SCENE_FUNCTION_LABELS = { + "false_peace": "表面平静", + "temptation": "试探", + "truth_trial": "真相逼近", + "mask_crack": "裂口", + "confession_window": "真话窗口", + "debt_exchange": "旧账回潮", + "karma_ripening": "因果回响", + "humiliation": "难堪代价", + "vow_payment": "誓言偿付", + "misrecognition": "误解升级", + "mercy_vs_control": "庇护与控制", +} + +SCENE_CARD_MARKERS = [ + "门影", + "案角", + "灯芯", + "窗纸", + "杯沿", + "衣袖", + "阶前风", + "纸页声", + "檐下光", + "桌沿冷响", +] + +SCENE_CARD_PRESSURES = [ + "把没说完的话压到明处", + "逼人物换一种动作承认", + "让旧账落回当前选择", + "把退路收窄成当面回应", + "让沉默不再能替人圆场", + "把关系债推到下一步之前", +] + +CHAPTER_TITLE_PRESSURES = { + "false_peace": ["静处起波", "暗潮照面", "退路收窄", "平静失衡"], + "temptation": ["试探加深", "半句成债", "退路被问", "分寸松动"], + "truth_trial": ["真相抵近", "旧证上桌", "隐情照面", "追问落地"], + "mask_crack": ["裂口显形", "伪装松开", "遮掩失手", "旧面具裂开"], + "confession_window": ["真话临窗", "窗口未合", "迟话上桌", "坦白逼近"], + "debt_exchange": ["旧账回潮", "亏欠落座", "债口重开", "后果换手"], + "karma_ripening": ["因果回响", "旧种成熟", "回声逼近", "前因追身"], + "humiliation": ["难堪上场", "体面受审", "众目压身", "羞意落地"], + "vow_payment": ["誓言偿付", "旧誓索价", "承诺见血", "代价临门"], + "misrecognition": ["误认加深", "错看成局", "半信成伤", "误会转向"], + "mercy_vs_control": ["庇护成锁", "控制露面", "温情施压", "退路被握"], +} + +CHAPTER_TITLE_FALLBACK_PRESSURES = ["选择收紧", "后果照面", "关系转向", "真话逼近", "旧事回潮"] + +CHAPTER_TITLE_FRAMES = [ + "{anchor}里的{pressure}", + "{marker}照出{pressure}", + "{actors}被{pressure}追上", + "{anchor}把{pressure}推近", + "{pressure}落到{anchor}", + "{marker}旁的{pressure}", +] + + +def _render_spec_length_bounds(render_spec: SceneRenderSpec) -> tuple[int, int]: + target = int(render_spec.target_word_count or 2000) + minimum = int(render_spec.min_target_word_count or max(200, target - 200)) + maximum = int(render_spec.max_target_word_count or max(target, target + 200)) + return minimum, maximum + + +RENDERED_SCENE_REQUIRED_KEYS = {"concise_summary", "interactive_scene", "premium_prose"} + + +def _payload_missing_required_keys(payload: Dict[str, Any]) -> List[str]: + return sorted(key for key in RENDERED_SCENE_REQUIRED_KEYS if key not in payload) + + +def _event_actor_boundary_violations( + text: str, + state_before: NarrativeState, + scene_beats: List[SceneBeat], +) -> List[str]: + allowed_actor_ids = { + str(actor_id) + for beat in scene_beats + for actor_id in list(beat.event.actors or []) + if str(actor_id) + } + violations: List[str] = [] + for actor_id, character in state_before.characters.items(): + if actor_id in allowed_actor_ids: + continue + name = str(getattr(character, "name", "") or "").strip() + if len(name) >= 2 and name in text: + violations.append(name) + return sorted(dict.fromkeys(violations)) + + +def _render_scene_payload_gate( + payload: Dict[str, Any], + *, + state_before: NarrativeState, + scene_beats: List[SceneBeat], + minimum: int, + maximum: int, +) -> Dict[str, Any]: + missing_keys = _payload_missing_required_keys(payload) + if missing_keys: + return { + "ok": False, + "fallback_reason": "invalid_llm_payload", + "missing_required_keys": missing_keys, + "grounding_issues": [], + "length_gate": None, + } + + premium_prose = str(payload.get("premium_prose") or "") + lint_report = lint_chapter_draft(premium_prose) + prose_units = int(lint_report.get("text_unit_count") or story_text_unit_count(premium_prose)) + meta_leaks = list(lint_report.get("meta_leaks") or []) + disallowed_latin_hits = list(lint_report.get("disallowed_latin_token_hits") or []) + actor_violations = _event_actor_boundary_violations( + premium_prose, + state_before, + scene_beats, + ) + grounding_issues: List[Dict[str, Any]] = [] + if meta_leaks: + grounding_issues.append( + { + "issue": "forbidden_engineering_or_meta_leak", + "evidence": meta_leaks[:5], + } + ) + if disallowed_latin_hits: + grounding_issues.append( + { + "issue": "disallowed_latin_token", + "evidence": [dict(item) for item in disallowed_latin_hits[:5]], + } + ) + if actor_violations: + grounding_issues.append( + { + "issue": "event_actor_boundary_violation", + "evidence": actor_violations, + } + ) + length_ok = minimum <= prose_units <= maximum + fallback_reason = None + if grounding_issues: + fallback_reason = "llm_grounding_gate_failed" + elif not length_ok: + fallback_reason = "llm_length_gate_failed" + return { + "ok": not grounding_issues and length_ok, + "fallback_reason": fallback_reason, + "missing_required_keys": [], + "grounding_issues": grounding_issues, + "length_gate": { + "observed_units": prose_units, + "min_target_word_count": minimum, + "max_target_word_count": maximum, + "ok": length_ok, + }, + "lint_metrics": { + "text_unit_count": prose_units, + "dialogue_count": lint_report.get("dialogue_count"), + "action_count": lint_report.get("action_count"), + "detail_count": lint_report.get("detail_count"), + "meta_leak_count": len(meta_leaks), + "disallowed_latin_token_count": len(disallowed_latin_hits), + }, + } + + +def _length_retry_user_prompt(user_prompt: str, gate: Dict[str, Any], *, minimum: int, maximum: int) -> str: + length_gate = dict(gate.get("length_gate") or {}) + observed = int(length_gate.get("observed_units") or 0) + return ( + f"{user_prompt}\n\n" + "Renderer retry instruction: the previous premium_prose failed the length gate " + f"with {observed} text units. Rewrite the full JSON response so premium_prose is " + f"between {minimum} and {maximum} Chinese text units, while preserving the same event, " + "actor boundary, facts, and continuation pressure. Output JSON only." + ) + class Renderer(ABC): @abstractmethod @@ -66,6 +254,116 @@ def _tag_labels(world: WorldBible, tags: List[str]) -> str: return "、".join(readable) if readable else "命运的轻微偏转" +def _scene_label(scene_function: str) -> str: + return SCENE_FUNCTION_LABELS.get(scene_function, scene_function.replace("_", " ")) + + +def _scene_card_seed(state_after: NarrativeState, event: EventAtom, *, offset: int = 0) -> int: + raw = f"{event.event_id}:{event.scene_function}:{event.location}:{state_after.chapter_index}:{offset}" + return sum(ord(char) for char in raw) + int(state_after.chapter_index or 0) * 37 + + +def _clean_title_fragment(value: str, *, fallback: str = "", max_chars: int = 10) -> str: + text = str(value or "").strip() + text = re.sub(r"[A-Za-z][A-Za-z0-9_-]*", "", text) + text = re.sub(r"\s*·\s*\d+\s*$", "", text) + text = re.sub(r"\s+", "", text) + text = text.strip(" ·::/|_-,。;;、") + text = re.sub(r"^[,、]+|[,、]+$", "", text) + if not text: + text = fallback + return str(text or "")[:max_chars] + + +def _actor_names_for_event(state: NarrativeState, event: EventAtom) -> str: + names = [] + for actor_id in event.actors[:2]: + character = state.characters.get(actor_id) + names.append(character.name if character else actor_id.replace("_", " ")) + return "、".join(name for name in names if name) or "众人" + + +def _reader_chapter_title( + world: WorldBible, + state_before: NarrativeState, + state_after: NarrativeState, + chapter_plan: ChapterPlan, + scene_beats: List[SceneBeat], +) -> str: + event = scene_beats[-1].event + chapter_index = int(state_after.chapter_index or chapter_plan.chapter_index or 0) + seed = _scene_card_seed(state_after, event, offset=70) + pressure_pool = CHAPTER_TITLE_PRESSURES.get(event.scene_function) or CHAPTER_TITLE_FALLBACK_PRESSURES + pressure = pressure_pool[seed % len(pressure_pool)] + marker = SCENE_CARD_MARKERS[_scene_card_seed(state_after, event, offset=71) % len(SCENE_CARD_MARKERS)] + location = _clean_title_fragment(event.location, max_chars=8) + event_anchor = _clean_title_fragment(event.title, fallback=chapter_plan.scene_intent.label, max_chars=8) + anchor = location or event_anchor or marker + actors = _actor_names_for_event(state_before, event) + frame = CHAPTER_TITLE_FRAMES[_scene_card_seed(state_after, event, offset=72) % len(CHAPTER_TITLE_FRAMES)] + title_tail = frame.format(anchor=anchor, marker=marker, actors=actors, pressure=pressure) + if not title_tail or title_tail == pressure: + title_tail = f"{_tag_labels(world, event.tags[:1] or ['destiny'])}里的{pressure}" + title_tail = _clean_title_fragment(title_tail, fallback=pressure, max_chars=16) + return f"第 {chapter_index} 章 · {title_tail}" + + +def _chapter_summary(world: WorldBible, state_before: NarrativeState, state_after: NarrativeState, event: EventAtom) -> str: + scene_label = _scene_label(event.scene_function) + location = event.location or "场面" + actors = _actor_names_for_event(state_before, event) + tags = _tag_labels(world, event.tags[:2] or ["destiny"]) + marker = SCENE_CARD_MARKERS[_scene_card_seed(state_after, event, offset=1) % len(SCENE_CARD_MARKERS)] + pressure = SCENE_CARD_PRESSURES[_scene_card_seed(state_after, event, offset=2) % len(SCENE_CARD_PRESSURES)] + variants = [ + f"{location}里的{marker}把{scene_label}推向{actors},{tags}不再只是背景。", + f"{actors}在{location}接住{scene_label},{marker}先把后果照清。", + f"{scene_label}沿着{location}的{marker}收紧,{pressure}。", + f"{location}这一章把{tags}压进{marker}和人物动作里,{scene_label}换了方向。", + f"{marker}先动了一下,{actors}被迫把{scene_label}从旧说法里拆出来。", + ] + return variants[_scene_card_seed(state_after, event, offset=3) % len(variants)] + + +def _reader_pull_quote(body: str, state_after: NarrativeState, scene_beats: List[SceneBeat]) -> str: + candidates = [ + item.strip() + for item in re.findall(r"“([^”]{6,42})”", body or "") + if item.strip() and not item.strip().startswith("这里会显示") + ] + if candidates: + event = scene_beats[-1].event + chosen = candidates[_scene_card_seed(state_after, event, offset=4) % len(candidates)] + return f"“{chosen}”" + last_event = scene_beats[-1].event + fallback = last_event.title if len(last_event.title) <= 24 else last_event.summary[:24] + return f"“{fallback}”" + + +def _reader_story_beats(world: WorldBible, state_before: NarrativeState, state_after: NarrativeState, scene_beats: List[SceneBeat]) -> List[str]: + beats: List[str] = [] + for index, beat in enumerate(scene_beats): + event = beat.event + scene_label = _scene_label(event.scene_function) + location = event.location or "场面" + marker = SCENE_CARD_MARKERS[_scene_card_seed(state_after, event, offset=10 + index) % len(SCENE_CARD_MARKERS)] + pressure = SCENE_CARD_PRESSURES[_scene_card_seed(state_after, event, offset=20 + index) % len(SCENE_CARD_PRESSURES)] + actor_names = _actor_names_for_event(state_before, event) + raw_label = str(beat.beat_label or event.title or scene_label) + if ":" in raw_label: + raw_label = raw_label.split(":", 1)[-1].strip() + raw_label = raw_label.strip(" ·::/|_-") + variants = [ + f"{location}的{marker}把{scene_label}落到{actor_names}的动作里。", + f"{raw_label[:18]}不再只是旧事,{marker}{pressure}。", + f"{actor_names}围着{marker}重新接住{scene_label},场面转向下一层。", + f"{scene_label}从{location}的{marker}露出,逼人物把后半句说实。", + f"{marker}和{location}里的细响一起改变{scene_label}的方向。", + ] + beats.append(variants[_scene_card_seed(state_after, event, offset=30 + index) % len(variants)]) + return beats + + class TemplateRenderer(Renderer): def render( self, @@ -99,11 +397,13 @@ def render( render_spec = SceneRenderSpec( prose_mode="novel_lush", viewpoint_character=event.actors[0] if event.actors else "", - target_word_count=900, + target_word_count=int(state_after.word_budget or state_before.word_budget or 2000), dialogue_density=0.35, sensory_motifs=list(event.tags[:2]), emotional_pivot=event.scene_function, ending_cadence="lingering", + min_target_word_count=max(200, int(state_after.word_budget or state_before.word_budget or 2000) - 200), + max_target_word_count=max(int(state_after.word_budget or state_before.word_budget or 2000), int(state_after.word_budget or state_before.word_budget or 2000) + 200), must_include_beats=[event.title], ) return self.render_scene(world, state_before, state_after, chapter_plan, [beat], render_spec) @@ -117,6 +417,7 @@ def render_scene( scene_beats: List[SceneBeat], render_spec: SceneRenderSpec, ) -> RenderedScene: + render_started = perf_counter() last_event = scene_beats[-1].event scene_plan = build_scene_plan( world=world, @@ -126,6 +427,7 @@ def render_scene( scene_beats=scene_beats, ending_hook=last_event.summary.rstrip("。"), ) + draft_started = perf_counter() draft = write_chapter_draft( world=world, state_before=state_before, @@ -133,13 +435,16 @@ def render_scene( scene_beats=scene_beats, render_spec=render_spec, ) + draft_elapsed_ms = round((perf_counter() - draft_started) * 1000.0, 3) + lint_started = perf_counter() lint_report = lint_chapter_draft(draft.body) + lint_elapsed_ms = round((perf_counter() - lint_started) * 1000.0, 3) body = lint_report["cleaned_text"] style_pack = style_pack_from_world(world) hook_templates = style_pack.hook_templates or ["这场话虽然停住了,可真正的余波还在后面等着。"] - title = "第 %s 章 · %s" % (state_after.chapter_index, chapter_plan.scene_intent.label) - summary = "这一步围绕 %s 继续收紧。" % _tag_labels(world, last_event.tags[:2] or ["destiny"]) - quote = "“%s”" % (last_event.title if len(last_event.title) <= 24 else last_event.summary[:24]) + title = _reader_chapter_title(world, state_before, state_after, chapter_plan, scene_beats) + summary = _chapter_summary(world, state_before, state_after, last_event) + quote = _reader_pull_quote(body, state_after, scene_beats) return RenderedScene( event_id=last_event.event_id, concise_summary="%s。%s" % (last_event.summary.rstrip("。"), hook_templates[0]), @@ -148,7 +453,7 @@ def render_scene( story_title=title, chapter_summary=summary, pull_quote=quote, - story_beats=[beat.event.title for beat in scene_beats], + story_beats=_reader_story_beats(world, state_before, state_after, scene_beats), visual_details=[ "地点:%s" % (scene_beats[0].event.location or "未指定"), "情绪:%s" % _tag_labels(world, last_event.tags[:2] or ["destiny"]), @@ -169,6 +474,11 @@ def render_scene( "renderer": "template", "scene_plan": scene_plan.to_dict(), "draft_metadata": dict(draft.metadata), + "timing_ms": { + "write_draft": draft_elapsed_ms, + "post_repair_lint": lint_elapsed_ms, + "total_render_scene": round((perf_counter() - render_started) * 1000.0, 3), + }, "lint_report": { key: value for key, value in lint_report.items() @@ -179,9 +489,47 @@ def render_scene( class LLMRenderer(Renderer): - def __init__(self, backend: LLMBackend, fallback_renderer: Renderer) -> None: + def __init__(self, backend: LLMBackend, fallback_renderer: Renderer, *, length_retry_attempts: int = 1) -> None: self.backend = backend self.fallback_renderer = fallback_renderer + self.length_retry_attempts = max(0, int(length_retry_attempts)) + + def _rendered_scene_from_payload( + self, + payload: Dict[str, Any], + *, + event: EventAtom, + fallback_title: str, + render_spec: SceneRenderSpec | None = None, + gate: Dict[str, Any] | None = None, + renderer_attempt_count: int = 1, + ) -> RenderedScene: + debug = { + "renderer": "llm", + "raw_payload": payload, + "backend_routing": backend_debug_info(self.backend), + "renderer_attempt_count": renderer_attempt_count, + } + if render_spec is not None: + debug["render_spec"] = render_spec.to_dict() + if gate is not None: + debug["llm_payload_gate"] = dict(gate) + return RenderedScene( + event_id=event.event_id, + concise_summary=str(payload["concise_summary"]), + interactive_scene=str(payload["interactive_scene"]), + premium_prose=str(payload["premium_prose"]), + story_title=str(payload.get("story_title", fallback_title)), + chapter_summary=str(payload.get("chapter_summary", payload["concise_summary"])), + pull_quote=str(payload.get("pull_quote", "")), + story_beats=list(payload.get("story_beats", [])), + visual_details=list(payload.get("visual_details", [])), + visual_prompt=str(payload.get("visual_prompt", "")), + image_caption=str(payload.get("image_caption", payload["concise_summary"])), + image_motif=str(payload.get("image_motif", event.scene_function)), + palette_hint=str(payload.get("palette_hint", "")), + debug=debug, + ) def render( self, @@ -207,27 +555,95 @@ def render( fallback.debug["backend_routing"] = backend_debug_info(self.backend) return fallback if isinstance(payload, dict): - required = {"concise_summary", "interactive_scene", "premium_prose"} - if required.issubset(payload.keys()): - return RenderedScene( - event_id=event.event_id, - concise_summary=str(payload["concise_summary"]), - interactive_scene=str(payload["interactive_scene"]), - premium_prose=str(payload["premium_prose"]), - story_title=str(payload.get("story_title", event.title)), - chapter_summary=str(payload.get("chapter_summary", payload["concise_summary"])), - pull_quote=str(payload.get("pull_quote", "")), - story_beats=list(payload.get("story_beats", [])), - visual_details=list(payload.get("visual_details", [])), - visual_prompt=str(payload.get("visual_prompt", "")), - image_caption=str(payload.get("image_caption", payload["concise_summary"])), - image_motif=str(payload.get("image_motif", event.scene_function)), - palette_hint=str(payload.get("palette_hint", "")), - debug={"renderer": "llm", "raw_payload": payload, "backend_routing": backend_debug_info(self.backend)}, - ) + if not _payload_missing_required_keys(payload): + return self._rendered_scene_from_payload(payload, event=event, fallback_title=event.title) fallback = self.fallback_renderer.render(world, state_before, state_after, event) fallback.debug["renderer_fallback_reason"] = "invalid_llm_payload" fallback.debug["renderer"] = "llm_fallback_template" fallback.debug["raw_payload"] = payload if isinstance(payload, dict) else {"payload": payload} fallback.debug["backend_routing"] = backend_debug_info(self.backend) return fallback + + def render_scene( + self, + world: WorldBible, + state_before: NarrativeState, + state_after: NarrativeState, + chapter_plan: ChapterPlan, + scene_beats: List[SceneBeat], + render_spec: SceneRenderSpec, + ) -> RenderedScene: + if not scene_beats: + raise ValueError("scene_beats must not be empty") + system_prompt = get_prompt_text("renderer") + user_prompt = render_scene_user_prompt( + world=world, + state_before=state_before, + state_after=state_after, + event=scene_beats[-1].event, + chapter_plan=chapter_plan, + scene_beats=scene_beats, + render_spec=render_spec, + ) + minimum, maximum = _render_spec_length_bounds(render_spec) + last_payload: Any = None + last_gate: Dict[str, Any] = {} + attempts = self.length_retry_attempts + 1 + attempted_count = 0 + for attempt_index in range(attempts): + attempted_count = attempt_index + 1 + active_user_prompt = ( + user_prompt + if attempt_index == 0 + else _length_retry_user_prompt(user_prompt, last_gate, minimum=minimum, maximum=maximum) + ) + try: + payload = self.backend.generate_json(system_prompt=system_prompt, user_prompt=active_user_prompt) + except Exception as exc: + fallback = self.fallback_renderer.render_scene(world, state_before, state_after, chapter_plan, scene_beats, render_spec) + fallback.debug["renderer_fallback_reason"] = "llm_backend_error" + fallback.debug["renderer"] = "llm_fallback_template" + fallback.debug["raw_payload"] = {"error": str(exc)} + fallback.debug["backend_routing"] = backend_debug_info(self.backend) + fallback.debug["renderer_attempt_count"] = attempted_count + return fallback + last_payload = payload + if not isinstance(payload, dict): + break + gate = _render_scene_payload_gate( + payload, + state_before=state_before, + scene_beats=scene_beats, + minimum=minimum, + maximum=maximum, + ) + last_gate = gate + if gate.get("ok"): + return self._rendered_scene_from_payload( + payload, + event=scene_beats[-1].event, + fallback_title=chapter_plan.scene_intent.label, + render_spec=render_spec, + gate=gate, + renderer_attempt_count=attempted_count, + ) + if gate.get("fallback_reason") == "llm_length_gate_failed" and attempt_index < attempts - 1: + continue + fallback = self.fallback_renderer.render_scene(world, state_before, state_after, chapter_plan, scene_beats, render_spec) + fallback.debug["renderer_fallback_reason"] = str(gate.get("fallback_reason") or "invalid_llm_payload") + fallback.debug["renderer"] = "llm_fallback_template" + fallback.debug["raw_payload"] = payload + fallback.debug["backend_routing"] = backend_debug_info(self.backend) + fallback.debug["llm_payload_gate"] = gate + fallback.debug["renderer_attempt_count"] = attempted_count + if gate.get("length_gate"): + fallback.debug["llm_length_gate"] = dict(gate.get("length_gate") or {}) + return fallback + fallback = self.fallback_renderer.render_scene(world, state_before, state_after, chapter_plan, scene_beats, render_spec) + fallback.debug["renderer_fallback_reason"] = "invalid_llm_payload" + fallback.debug["renderer"] = "llm_fallback_template" + fallback.debug["raw_payload"] = last_payload if isinstance(last_payload, dict) else {"payload": last_payload} + fallback.debug["backend_routing"] = backend_debug_info(self.backend) + fallback.debug["llm_payload_gate"] = last_gate + fallback.debug["renderer_attempt_count"] = attempted_count + return fallback diff --git a/src/narrativeos/repetition_detector.py b/src/narrativeos/repetition_detector.py index 33af9eb..4df5aba 100644 --- a/src/narrativeos/repetition_detector.py +++ b/src/narrativeos/repetition_detector.py @@ -2,16 +2,556 @@ import re from collections import Counter -from typing import Iterable +from functools import lru_cache +from itertools import combinations +from math import sqrt +from typing import Dict, Iterable, List, Sequence, Tuple + + +_SPLIT_PATTERN = re.compile(r"[\s,。、“”‘’!?:;,.!?]+") +_TEXT_UNIT_PATTERN = re.compile(r"[\u4e00-\u9fffA-Za-z0-9]") +_SENTENCE_SPLIT_PATTERN = re.compile(r"[。!?!?]+") +_DISALLOWED_LATIN_ANCHOR_PATTERN = re.compile(r"\b[a-zA-Z_][A-Za-z0-9_]*\b") +LONG_ROUTE_SUSPICIOUS_REFRAINS = ( + "眼前这一处", + "这一处", + "真话窗口", + "把每一步都接住", + "别再漏掉", + "真正要转向的那句终于逼到眼前", + "被压回去的", + "顺着此刻的局势先退半步再找一个更稳的开口", +) +SCENE_FUNCTION_LABELS = { + "false_peace": "表面平静", + "temptation": "试探", + "truth_trial": "真相逼近", + "mask_crack": "裂口", + "confession_window": "真话窗口", + "debt_exchange": "旧账回潮", + "karma_ripening": "因果回响", + "humiliation": "难堪代价", + "vow_payment": "誓言偿付", + "misrecognition": "误解升级", +} + + +def _tokenize(line: str) -> List[str]: + return [token for token in _SPLIT_PATTERN.split(str(line or "")) if token] + + +def _normalized_text_units(text: str) -> str: + return "".join(_TEXT_UNIT_PATTERN.findall(str(text or ""))) + + +@lru_cache(maxsize=16384) +def _cached_char_ngrams(text: str, size: int) -> Tuple[str, ...]: + normalized = _normalized_text_units(text) + if len(normalized) < size: + return () + return tuple(normalized[index : index + size] for index in range(0, len(normalized) - size + 1)) + + +def _char_ngrams(text: str, size: int) -> List[str]: + return list(_cached_char_ngrams(str(text or ""), int(size))) + + +def _jaccard_similarity(left: Sequence[str], right: Sequence[str]) -> float: + left_set = set(left) + right_set = set(right) + if not left_set and not right_set: + return 0.0 + return len(left_set & right_set) / float(max(1, len(left_set | right_set))) + + +def _build_semantic_feature_vector(text: str) -> Counter[str]: + features: Counter[str] = Counter() + raw_text = str(text or "") + for token in _tokenize(raw_text): + features[f"w:{token}"] += 1 + normalized = _normalized_text_units(raw_text) + for size in (2, 3, 4): + if len(normalized) < size: + continue + for index in range(0, len(normalized) - size + 1): + features[f"c{size}:{normalized[index:index + size]}"] += 1 + return features + + +@lru_cache(maxsize=16384) +def _cached_semantic_feature_items(text: str) -> Tuple[Tuple[str, int], ...]: + return tuple(sorted(_build_semantic_feature_vector(text).items())) + + +def _semantic_feature_vector(text: str) -> Counter[str]: + return Counter(dict(_cached_semantic_feature_items(str(text or "")))) + + +def _cosine_similarity(left: Counter[str], right: Counter[str]) -> float: + if not left or not right: + return 0.0 + left_items = left.items() + right_lookup = right + if len(right) < len(left): + left_items = right.items() + right_lookup = left + numerator = sum(float(value) * float(right_lookup.get(key, 0.0)) for key, value in left_items) + if numerator <= 0.0: + return 0.0 + left_norm = sqrt(sum(float(value) ** 2 for value in left.values())) + right_norm = sqrt(sum(float(value) ** 2 for value in right.values())) + denominator = left_norm * right_norm + if denominator <= 0.0: + return 0.0 + return numerator / denominator + + +def _anchor_token_coverage(paragraph: str, anchor: str) -> float: + paragraph_units = _normalized_text_units(paragraph) + anchor_tokens = [ + _normalized_text_units(token) + for token in _tokenize(anchor) + if len(_normalized_text_units(token)) >= 2 + ] + anchor_tokens = list(dict.fromkeys(anchor_tokens)) + if not paragraph_units or not anchor_tokens: + return 0.0 + matched = sum(1 for token in anchor_tokens if token in paragraph_units) + return matched / float(max(1, len(anchor_tokens))) + + +def _paragraph_similarity_score(lines: Sequence[str]) -> Tuple[float, List[Dict[str, object]]]: + paragraph_ngrams = [ + _char_ngrams(line, 6) + for line in lines + ] + scored_pairs: List[Dict[str, object]] = [] + similarities: List[float] = [] + for (left_index, left_ngrams), (right_index, right_ngrams) in combinations(enumerate(paragraph_ngrams), 2): + similarity = _jaccard_similarity(left_ngrams, right_ngrams) + if similarity <= 0.0: + continue + similarities.append(similarity) + scored_pairs.append( + { + "left_paragraph_index": left_index, + "right_paragraph_index": right_index, + "similarity": round(similarity, 3), + } + ) + scored_pairs.sort(key=lambda item: (-float(item["similarity"]), int(item["left_paragraph_index"]), int(item["right_paragraph_index"]))) + return (max(similarities) if similarities else 0.0), scored_pairs[:3] + + +def _semantic_paragraph_similarity_score(lines: Sequence[str]) -> Tuple[float, List[Dict[str, object]]]: + vectors = [_semantic_feature_vector(line) for line in lines] + scored_pairs: List[Dict[str, object]] = [] + similarities: List[float] = [] + for (left_index, left_vector), (right_index, right_vector) in combinations(enumerate(vectors), 2): + similarity = _cosine_similarity(left_vector, right_vector) + if similarity <= 0.0: + continue + similarities.append(similarity) + scored_pairs.append( + { + "left_paragraph_index": left_index, + "right_paragraph_index": right_index, + "similarity": round(similarity, 3), + "left_preview": str(lines[left_index])[:48], + "right_preview": str(lines[right_index])[:48], + } + ) + scored_pairs.sort(key=lambda item: (-float(item["similarity"]), int(item["left_paragraph_index"]), int(item["right_paragraph_index"]))) + return (max(similarities) if similarities else 0.0), scored_pairs[:3] + + +def _n_gram_repetition_score(lines: Sequence[str]) -> float: + grams: List[str] = [] + for line in lines: + grams.extend(_char_ngrams(line, 12)) + if not grams: + return 0.0 + counts = Counter(grams) + repeated_distinct = sum(1 for count in counts.values() if count > 1) + return repeated_distinct / float(len(counts)) + + +def _length_bucket(text: str) -> str: + size = len(_normalized_text_units(text)) + if size < 80: + return "short" + if size < 180: + return "medium" + return "long" + + +def _structure_signature(text: str) -> str: + normalized = _normalized_text_units(text) + action_markers = sum(text.count(marker) for marker in ["抬", "落", "偏", "按", "推", "站", "看", "握", "停", "拢", "压", "掠", "碰", "擦", "收", "绷", "卷", "撞", "回", "拨", "绕", "贴", "拖"]) + detail_markers = sum(text.count(marker) for marker in ["灯", "袖", "茶", "风", "门", "阶", "檐", "影", "衣", "案", "纸", "雨", "香", "窗"]) + return "|".join( + [ + _length_bucket(text), + "dialogue" if "“" in text else "narration", + "hook" if any(token in text for token in ["下一次", "还会", "还没", "追上来", "没有散", "未说尽"]) else "plain", + "action_high" if action_markers >= 8 else ("action_mid" if action_markers >= 4 else "action_low"), + "detail_high" if detail_markers >= 6 else ("detail_mid" if detail_markers >= 3 else "detail_low"), + normalized[:12], + ] + ) + + +def _beat_structure_repetition_score(lines: Sequence[str]) -> float: + signatures = [_structure_signature(line) for line in lines if _normalized_text_units(line)] + if not signatures: + return 0.0 + counts = Counter(signatures) + repeated = sum(count - 1 for count in counts.values() if count > 1) + return repeated / float(len(signatures)) + + +def _suspicious_refrain_count(lines: Sequence[str]) -> Tuple[int, List[str]]: + fragments: List[str] = [] + raw_text = "\n".join(str(line or "") for line in lines) + for line in lines: + for sentence in _SENTENCE_SPLIT_PATTERN.split(str(line or "")): + normalized = _normalized_text_units(sentence) + if len(normalized) >= 14: + fragments.append(normalized) + counts = Counter(fragments) + repeated = sorted( + [fragment for fragment, count in counts.items() if count >= 2], + key=lambda fragment: (-counts[fragment], fragment), + ) + known_refrains = [] + normalized_raw_text = _normalized_text_units(raw_text) + for phrase in LONG_ROUTE_SUSPICIOUS_REFRAINS: + normalized_phrase = _normalized_text_units(phrase) + if normalized_phrase and normalized_raw_text.count(normalized_phrase) >= 2: + known_refrains.append(normalized_phrase) + combined = list(dict.fromkeys(repeated + known_refrains)) + return len(combined), [fragment[:32] for fragment in combined[:3]] + + +def _payload_from_beat(raw: object) -> Dict[str, object]: + if hasattr(raw, "to_dict"): + raw = raw.to_dict() + payload = dict(raw or {}) + event = payload.get("event") or {} + if hasattr(event, "to_dict"): + event = event.to_dict() + event_payload = dict(event or {}) + return { + "event_id": str(event_payload.get("event_id") or ""), + "event_title": str(event_payload.get("title") or ""), + "event_summary": str(event_payload.get("summary") or ""), + "scene_function": str(event_payload.get("scene_function") or ""), + "location": str(event_payload.get("location") or ""), + "tags": [str(item) for item in list(event_payload.get("tags") or []) if str(item).strip()], + "beat_label": str(payload.get("beat_label") or ""), + "dramatic_job": str(payload.get("dramatic_job") or ""), + } + + +def _reader_visible_anchor_text(text: str) -> str: + cleaned = _DISALLOWED_LATIN_ANCHOR_PATTERN.sub(" ", str(text or "")) + cleaned = re.sub(r"让人物进一步卷入[^。!?!?]*[。!?!?]?", " ", cleaned) + cleaned = re.sub(r"这一拍不再新增事件[^。!?!?]*[。!?!?]?", " ", cleaned) + cleaned = cleaned.replace("真正要转向的那句终于逼到眼前", " ") + cleaned = re.sub(r"刚才没说透的态度、代价和退路都被逼到明处[。!?!?]?", " ", cleaned) + cleaned = re.sub(r"\b中[,,]\s*", " ", cleaned) + cleaned = re.sub(r"\s+", " ", cleaned) + cleaned = re.sub(r"(?:\s*[·::/|_-]\s*){2,}", " · ", cleaned) + return cleaned.strip(" ·::/|_-") + + +def _compact_anchor_component(text: str, *, max_chars: int = 28) -> str: + cleaned = _reader_visible_anchor_text(text) + cleaned = re.sub(r"(?:\s*[·::/|_-]\s*\d+\s*)+$", "", cleaned).strip(" ·::/|_-") + if not cleaned: + return "" + parts = [part.strip(" ·::/|_-") for part in re.split(r"\s*·\s*", cleaned) if part.strip(" ·::/|_-")] + generic_fragments = ("真正要转向", "这一拍留下来的余波", "说出口后的余波") + meaningful = [part for part in parts if not any(fragment in part for fragment in generic_fragments)] + candidate = meaningful[0] if meaningful else cleaned + return candidate[:max(4, int(max_chars))] + + +def _salient_anchor_terms(payload: Dict[str, object]) -> List[str]: + terms: List[str] = [] + title = _reader_visible_anchor_text(str(payload.get("event_title") or "")) + summary = _reader_visible_anchor_text(str(payload.get("event_summary") or "")) + location = str(payload.get("location") or "").strip() + scene_label = SCENE_FUNCTION_LABELS.get(str(payload.get("scene_function") or ""), "") + for source in (title, summary): + if not source: + continue + match = re.match(r"([\u4e00-\u9fff]{2,5})(?:在|把|顺着|当|借|递|没有|先|看|听|抬|走|停|接|问|逼|压)", source) + if match: + terms.append(match.group(1)) + for cue in ("余澄", "林绾", "徐师", "荣老太君", "书房", "回廊", "花厅", "春闱", "真话", "退路", "代价", "试探", "认", "糊涂"): + if cue in source: + terms.append(cue) + if location: + terms.append(location) + if scene_label: + terms.append(scene_label) + return list(dict.fromkeys(term for term in terms if term)) + + +def _event_anchor_text(payload: Dict[str, object]) -> str: + scene_label = SCENE_FUNCTION_LABELS.get(str(payload.get("scene_function") or ""), "") + salient_terms = _salient_anchor_terms(payload) + title_anchor = _compact_anchor_component(str(payload.get("event_title") or ""), max_chars=26) + summary_anchor = _compact_anchor_component(str(payload.get("event_summary") or ""), max_chars=30) + tag_anchors: List[str] = [] + for raw_tag in list(payload.get("tags") or [])[:3]: + tag_anchor = _reader_visible_anchor_text(str(raw_tag or "")) + if not tag_anchor: + continue + tag_anchors.append(tag_anchor[:42]) + return _reader_visible_anchor_text(" ".join( + item + for item in [ + " ".join(salient_terms), + "" if salient_terms else title_anchor, + "" if salient_terms else summary_anchor, + str(payload.get("location") or "").strip(), + scene_label, + " ".join(tag_anchors), + ] + if item + ).strip()) + + +def _beat_anchor_text(payload: Dict[str, object]) -> str: + scene_label = SCENE_FUNCTION_LABELS.get(str(payload.get("scene_function") or ""), "") + salient_terms = _salient_anchor_terms(payload) + title_anchor = _compact_anchor_component(str(payload.get("event_title") or ""), max_chars=26) + summary_anchor = _compact_anchor_component(str(payload.get("event_summary") or ""), max_chars=30) + return _reader_visible_anchor_text(" ".join( + item + for item in [ + " ".join(salient_terms), + _compact_anchor_component(str(payload.get("beat_label") or ""), max_chars=24), + str(payload.get("dramatic_job") or "").strip(), + scene_label, + "" if salient_terms else title_anchor, + "" if salient_terms else summary_anchor, + ] + if item + ).strip()) + + +def _best_anchor_scores( + lines: Sequence[str], + paragraph_vectors: Sequence[Counter[str]], + anchor_texts: Sequence[str], + anchor_vectors: Sequence[Counter[str]], +) -> List[float]: + scores: List[float] = [] + for anchor_text, anchor_vector in zip(anchor_texts, anchor_vectors): + vector_score = max((_cosine_similarity(paragraph_vector, anchor_vector) for paragraph_vector in paragraph_vectors), default=0.0) + token_score = max((_anchor_token_coverage(line, anchor_text) for line in lines), default=0.0) + score = max(vector_score, min(1.0, token_score) * 0.50) + scores.append(score) + return scores + + +def _coverage_gap_signal_bundle( + lines: Sequence[str], + *, + coverage_context: Dict[str, object] | None = None, +) -> Dict[str, object]: + context = dict(coverage_context or {}) + raw_beats = list(context.get("scene_beats") or []) + beat_payloads = [_payload_from_beat(item) for item in raw_beats] + selected_event_ids = list( + dict.fromkeys( + str(item) + for item in list(context.get("selected_event_ids") or []) + if str(item).strip() + ) + ) + if selected_event_ids: + beat_payloads = [item for item in beat_payloads if item.get("event_id") in selected_event_ids] or beat_payloads + if not beat_payloads or not lines: + return { + "selected_event_ids": selected_event_ids, + "semantic_paragraph_similarity_score": 0.0, + "semantic_paragraph_similarity_pairs": [], + "event_coverage_gap_score": 0.0, + "beat_coverage_gap_score": 0.0, + "uncovered_event_count": 0, + "uncovered_beat_count": 0, + "overcovered_beat_count": 0, + "coverage_gap_examples": [], + } + + paragraph_vectors = [_semantic_feature_vector(line) for line in lines] + semantic_similarity_score, semantic_pairs = _semantic_paragraph_similarity_score(lines) + + event_anchors = [] + seen_event_ids = set() + for payload in beat_payloads: + event_id = str(payload.get("event_id") or "").strip() + anchor_text = _event_anchor_text(payload) + if not event_id or not anchor_text or event_id in seen_event_ids: + continue + seen_event_ids.add(event_id) + event_anchors.append( + { + "event_id": event_id, + "label": payload["event_title"] or event_id, + "text": anchor_text, + } + ) + beat_anchors = [ + { + "event_id": payload["event_id"], + "label": payload["beat_label"] or payload["event_title"] or payload["event_id"], + "text": _beat_anchor_text(payload), + } + for payload in beat_payloads + if _beat_anchor_text(payload) + ] + + event_texts = [item["text"] for item in event_anchors] + beat_texts = [item["text"] for item in beat_anchors] + event_vectors = [_semantic_feature_vector(text) for text in event_texts] + beat_vectors = [_semantic_feature_vector(text) for text in beat_texts] + event_best_scores = _best_anchor_scores(lines, paragraph_vectors, event_texts, event_vectors) if event_vectors else [] + raw_beat_best_scores = _best_anchor_scores(lines, paragraph_vectors, beat_texts, beat_vectors) if beat_vectors else [] + event_score_by_id = { + str(anchor.get("event_id") or ""): score + for anchor, score in zip(event_anchors, event_best_scores) + if str(anchor.get("event_id") or "") + } + beat_best_scores = [ + max(score, event_score_by_id.get(str(anchor.get("event_id") or ""), 0.0)) + for anchor, score in zip(beat_anchors, raw_beat_best_scores) + ] + + uncovered_event_count = sum(1 for score in event_best_scores if score < 0.16) + uncovered_beat_count = sum(1 for score in beat_best_scores if score < 0.14) + + paragraph_best_beats: List[int] = [] + for paragraph_vector in paragraph_vectors: + best_index = None + best_score = 0.0 + for beat_index, beat_vector in enumerate(beat_vectors): + score = _cosine_similarity(paragraph_vector, beat_vector) + if score > best_score: + best_score = score + best_index = beat_index + if best_index is not None and best_score >= 0.1: + paragraph_best_beats.append(best_index) + beat_assignment_counts = Counter(paragraph_best_beats) + expected_per_beat = max(1, round(len(lines) / float(max(1, len(beat_anchors))))) + overcovered_beat_count = sum(1 for count in beat_assignment_counts.values() if count > expected_per_beat + 1) + + event_coverage_gap_score = 0.0 + if event_best_scores: + event_coverage_gap_score = ( + sum(max(0.0, 1.0 - score) for score in event_best_scores) / float(len(event_best_scores)) + + (uncovered_event_count / float(len(event_best_scores))) + ) / 2.0 + beat_coverage_gap_score = 0.0 + if beat_best_scores: + beat_coverage_gap_score = ( + sum(max(0.0, 1.0 - score) for score in beat_best_scores) / float(len(beat_best_scores)) + + (uncovered_beat_count / float(len(beat_best_scores))) + + (overcovered_beat_count / float(len(beat_best_scores))) + ) / 3.0 + if beat_best_scores and uncovered_beat_count == 0 and beat_coverage_gap_score <= 0.35: + event_coverage_gap_score = min(event_coverage_gap_score, 0.42) + + examples: List[Dict[str, object]] = [] + for anchor, score in zip(event_anchors, event_best_scores): + if score < 0.16: + examples.append( + { + "kind": "uncovered_event", + "event_id": anchor["event_id"], + "label": anchor["label"], + "score": round(score, 3), + } + ) + for beat_index, (anchor, score) in enumerate(zip(beat_anchors, beat_best_scores)): + if score < 0.14: + examples.append( + { + "kind": "uncovered_beat", + "event_id": anchor["event_id"], + "label": anchor["label"], + "score": round(score, 3), + } + ) + if beat_assignment_counts.get(beat_index, 0) > expected_per_beat + 1: + examples.append( + { + "kind": "overcovered_beat", + "event_id": anchor["event_id"], + "label": anchor["label"], + "assigned_paragraph_count": int(beat_assignment_counts.get(beat_index, 0)), + } + ) + return { + "selected_event_ids": selected_event_ids or [item["event_id"] for item in beat_payloads if item["event_id"]], + "semantic_paragraph_similarity_score": round(semantic_similarity_score, 3), + "semantic_paragraph_similarity_pairs": semantic_pairs, + "event_coverage_gap_score": round(event_coverage_gap_score, 3), + "beat_coverage_gap_score": round(beat_coverage_gap_score, 3), + "uncovered_event_count": int(uncovered_event_count), + "uncovered_beat_count": int(uncovered_beat_count), + "overcovered_beat_count": int(overcovered_beat_count), + "coverage_gap_examples": examples[:5], + } def repetition_score(lines: Iterable[str]) -> float: tokens = [] for line in lines: - words = [token for token in re.split(r"[\s,。、“”‘’!?:;,.!?]+", line) if token] + words = _tokenize(line) tokens.extend(words) if not tokens: return 0.0 counts = Counter(tokens) repeated = sum(count - 1 for count in counts.values() if count > 1) return repeated / float(len(tokens)) + + +def repetition_signal_bundle( + lines: Iterable[str], + *, + coverage_context: Dict[str, object] | None = None, +) -> Dict[str, object]: + normalized_lines = [str(line or "").strip() for line in lines if str(line or "").strip()] + lexical = repetition_score(normalized_lines) + paragraph_similarity, top_pairs = _paragraph_similarity_score(normalized_lines) + n_gram = _n_gram_repetition_score(normalized_lines) + beat_structure = _beat_structure_repetition_score(normalized_lines) + suspicious_refrain_count, suspicious_examples = _suspicious_refrain_count(normalized_lines) + coverage_bundle = _coverage_gap_signal_bundle(normalized_lines, coverage_context=coverage_context) + overall = max( + lexical * 0.55, + paragraph_similarity * 0.7, + float(coverage_bundle["semantic_paragraph_similarity_score"]), + float(coverage_bundle["event_coverage_gap_score"]) * 0.95, + float(coverage_bundle["beat_coverage_gap_score"]), + n_gram * 0.35, + beat_structure * 0.85, + min(1.0, suspicious_refrain_count / 8.0), + min(1.0, (int(coverage_bundle["uncovered_beat_count"]) + int(coverage_bundle["overcovered_beat_count"])) / 4.0), + ) + return { + "lexical_repetition_score": round(lexical, 3), + "paragraph_similarity_score": round(paragraph_similarity, 3), + "n_gram_repetition_score": round(n_gram, 3), + "beat_structure_repetition_score": round(beat_structure, 3), + "suspicious_refrain_count": int(suspicious_refrain_count), + "suspicious_refrain_examples": suspicious_examples, + "top_repeated_paragraph_pairs": top_pairs, + **coverage_bundle, + "overall_repetition_pressure": round(overall, 3), + } diff --git a/src/narrativeos/runtime_env.py b/src/narrativeos/runtime_env.py new file mode 100644 index 0000000..c8c47aa --- /dev/null +++ b/src/narrativeos/runtime_env.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Dict, Iterable, List, Optional + + +ROOT_DIR = Path(__file__).resolve().parents[2] +DEFAULT_ENV_PATHS = ( + ROOT_DIR / ".env.local", + ROOT_DIR / ".env", +) + +_DEFAULT_ENV_LOADED = False + + +def _parse_env_line(raw_line: str) -> Optional[tuple[str, str]]: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + return None + if line.startswith("export "): + line = line[len("export "):].strip() + key, value = line.split("=", 1) + normalized_key = key.strip() + if not normalized_key: + return None + normalized_value = value.strip().strip('"').strip("'") + return normalized_key, normalized_value + + +def load_local_env( + *, + env_paths: Optional[Iterable[Path]] = None, + override_existing: bool = False, +) -> Dict[str, str]: + global _DEFAULT_ENV_LOADED + + using_default_paths = env_paths is None + if using_default_paths and _DEFAULT_ENV_LOADED and not override_existing: + return {} + + resolved_paths = list(env_paths or DEFAULT_ENV_PATHS) + loaded: Dict[str, str] = {} + for path in resolved_paths: + if not Path(path).exists(): + continue + for raw_line in Path(path).read_text(encoding="utf-8").splitlines(): + parsed = _parse_env_line(raw_line) + if parsed is None: + continue + key, value = parsed + if override_existing or key not in os.environ: + os.environ[key] = value + loaded[key] = value + + if using_default_paths and not override_existing: + _DEFAULT_ENV_LOADED = True + return loaded + + +def describe_env_sources(*, env_paths: Optional[Iterable[Path]] = None) -> List[str]: + return [str(path) for path in list(env_paths or DEFAULT_ENV_PATHS)] diff --git a/src/narrativeos/sanitizer.py b/src/narrativeos/sanitizer.py index 662ca3b..aea2733 100644 --- a/src/narrativeos/sanitizer.py +++ b/src/narrativeos/sanitizer.py @@ -1,7 +1,25 @@ from __future__ import annotations import re -from typing import Iterable +from typing import Dict, Iterable, List, Tuple + + +ENGINEERING_REPLACEMENTS = [ + (re.compile(r"(?"), - re.compile(r"\b[a-z]+(?:_[a-z0-9]+)+\b"), + re.compile(r"(? str: cleaned = text + for pattern, replacement in ENGINEERING_REPLACEMENTS: + cleaned = pattern.sub(replacement, cleaned) for pattern in ENGINEERING_PATTERNS: cleaned = pattern.sub("", cleaned) cleaned = re.sub(r"[ \t]{2,}", " ", cleaned) @@ -35,3 +113,117 @@ def contains_engineering_leak(text: str) -> bool: def sanitize_lines(lines: Iterable[str]) -> list[str]: return [sanitize_text(line) for line in lines if sanitize_text(line)] + + +def _sanitize_remaining_latin_tokens(text: str) -> Tuple[str, List[str]]: + sanitized_tokens: List[str] = [] + + def replace(match: re.Match[str]) -> str: + token = str(match.group(0) or "") + if LATIN_TOKEN_WHITELIST_PATTERN.fullmatch(token): + return token + sanitized_tokens.append(token) + replacement = READER_VISIBLE_LATIN_REPLACEMENTS.get(token.lower(), "") + return replacement + + cleaned = LATIN_TOKEN_PATTERN.sub(replace, str(text or "")) + cleaned = re.sub(r"[ \t]{2,}", " ", cleaned) + cleaned = re.sub(r" *\n *", "\n", cleaned) + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + cleaned = re.sub(r"\s+([,。!?;:,.!?])", r"\1", cleaned) + cleaned = re.sub(r"([(《“‘【])\s+", r"\1", cleaned) + cleaned = re.sub(r"\s+([)》”’】])", r"\1", cleaned) + return cleaned.strip(), list(dict.fromkeys(sanitized_tokens)) + + +def sanitize_reader_visible_text(text: str) -> str: + cleaned, _ = sanitize_reader_visible_text_with_report(text) + return cleaned + + +def sanitize_reader_visible_text_with_report(text: str) -> Tuple[str, Dict[str, object]]: + original_latin_tokens = [ + token + for token in LATIN_TOKEN_PATTERN.findall(str(text or "")) + if not LATIN_TOKEN_WHITELIST_PATTERN.fullmatch(token) + ] + base = sanitize_text(text) + cleaned, remaining_latin_tokens = _sanitize_remaining_latin_tokens(base) + sanitized_tokens = list(dict.fromkeys(original_latin_tokens + remaining_latin_tokens)) + return cleaned, { + "reader_visible_language_sanitized": bool(sanitized_tokens), + "sanitized_latin_tokens": sanitized_tokens, + } + + +def sanitize_reader_visible_lines(lines: Iterable[str]) -> list[str]: + output: list[str] = [] + for line in lines: + cleaned = sanitize_reader_visible_text(str(line or "")) + if cleaned: + output.append(cleaned) + return output + + +def _merge_reader_visible_language_reports(*reports: Dict[str, object]) -> Dict[str, object]: + sanitized = False + fields: List[str] = [] + tokens: List[str] = [] + for report in reports: + if not isinstance(report, dict): + continue + sanitized = sanitized or bool(report.get("reader_visible_language_sanitized")) + fields.extend([str(item) for item in list(report.get("fields") or []) if str(item).strip()]) + tokens.extend([str(item) for item in list(report.get("sanitized_latin_tokens") or []) if str(item).strip()]) + return { + "reader_visible_language_sanitized": sanitized, + "fields": list(dict.fromkeys(fields)), + "sanitized_latin_tokens": list(dict.fromkeys(tokens)), + } + + +def sanitize_reader_visible_payload(payload: Dict[str, object]) -> Tuple[Dict[str, object], Dict[str, object]]: + working = dict(payload or {}) + reports: List[Dict[str, object]] = [] + + def sanitize_field(name: str, value: str) -> str: + cleaned, report = sanitize_reader_visible_text_with_report(value) + if report["reader_visible_language_sanitized"]: + reports.append( + { + "reader_visible_language_sanitized": True, + "fields": [name], + "sanitized_latin_tokens": list(report["sanitized_latin_tokens"]), + } + ) + return cleaned + + working["chapter_title"] = sanitize_field("chapter_title", str(working.get("chapter_title") or "")) + working["recap"] = sanitize_field("recap", str(working.get("recap") or "")) + working["body"] = sanitize_field("body", str(working.get("body") or "")) + working["choices"] = [ + sanitize_field(f"choice[{index}]", str(item or "")) + for index, item in enumerate(list(working.get("choices") or [])) + ] + working["relationship_hints"] = [ + sanitize_field(f"relationship_hint[{index}]", str(item or "")) + for index, item in enumerate(list(working.get("relationship_hints") or [])) + ] + + scene_card = dict(working.get("scene_card") or {}) + if scene_card: + scene_card["title"] = sanitize_field("scene_card.title", str(scene_card.get("title") or "")) + scene_card["summary"] = sanitize_field("scene_card.summary", str(scene_card.get("summary") or "")) + scene_card["quote"] = sanitize_field("scene_card.quote", str(scene_card.get("quote") or "")) + scene_card["palette_hint"] = sanitize_field("scene_card.palette_hint", str(scene_card.get("palette_hint") or "")) + scene_card["story_beats"] = [ + sanitize_field(f"scene_card.story_beats[{index}]", str(item or "")) + for index, item in enumerate(list(scene_card.get("story_beats") or [])) + ] + scene_card["visual_details"] = [ + sanitize_field(f"scene_card.visual_details[{index}]", str(item or "")) + for index, item in enumerate(list(scene_card.get("visual_details") or [])) + ] + working["scene_card"] = scene_card + + return working, _merge_reader_visible_language_reports(*reports) diff --git a/src/narrativeos/scoring.py b/src/narrativeos/scoring.py index 83ecbdf..c5881d1 100644 --- a/src/narrativeos/scoring.py +++ b/src/narrativeos/scoring.py @@ -4,8 +4,11 @@ from .canon import hard_constraint_errors from .character_engine import choice_score +from .core.contracts import style_pack_from_world from .fate import destiny_alignment +from .longform import active_replan_debt from .models import EventAtom, NarrativeState, ScoredCandidate, SearchWeights, WorldBible +from .scene_functions import is_terminal_scene_function def _tokenize(parts: Iterable[str]) -> set[str]: @@ -25,6 +28,236 @@ def _keyword_overlap(a: Iterable[str], b: Iterable[str]) -> float: return float(len(set_a & set_b)) / float(len(set_a | set_b)) +_DUTY_ALIGNMENT_RULES: Dict[str, Dict[str, List[str]]] = { + "advance_plot": { + "scene_functions": ["truth_trial", "mask_crack", "debt_exchange", "karma_ripening", "temptation"], + "tags": ["truth", "destiny", "reputation", "system", "selfhood"], + }, + "advance_relationship": { + "scene_functions": ["temptation", "misrecognition", "confession_window", "truth_trial"], + "tags": ["love", "honesty", "selfhood", "loyalty", "curiosity"], + }, + "resolve_promise": { + "scene_functions": ["vow_payment", "confession_window", "truth_trial", "debt_exchange"], + "tags": ["truth", "honesty", "sacrifice", "loyalty", "choice"], + }, + "expand_world": { + "scene_functions": ["false_peace", "karma_ripening", "mask_crack", "truth_trial"], + "tags": ["system", "reform", "destiny", "reputation", "world"], + }, + "pace_breath": { + "scene_functions": ["false_peace", "confession_window", "misrecognition"], + "tags": ["hope", "mercy", "selfhood", "reflection", "earned_peace"], + }, + "deliver_climax": { + "scene_functions": ["vow_payment", "karma_ripening", "truth_trial", "debt_exchange"], + "tags": ["destiny", "selfhood", "truth", "sacrifice", "reputation"], + }, +} + + +def _event_signal_tokens(event: EventAtom) -> List[str]: + return ( + list(event.tags) + + list(event.agency_affordances) + + list(event.vow_tests) + + list(event.wound_triggers) + + [event.scene_function, event.summary] + ) + + +def _character_memory_payload( + state: NarrativeState, + actor_id: str, + *, + world: Optional[WorldBible] = None, +) -> Dict[str, object]: + runtime_entry = dict((state.character_memory_runtime or {}).get(actor_id) or {}) + if runtime_entry: + return runtime_entry + metadata = dict(getattr(getattr(world, "creator_controls", None), "metadata", {}) or {}) + profiles = dict(metadata.get("character_memory_profiles") or {}) + return dict(profiles.get(actor_id) or {}) + + +def _character_card_alignment( + state: NarrativeState, + event: EventAtom, + *, + world: Optional[WorldBible] = None, +) -> float: + actor_ids = [actor_id for actor_id in event.actors if actor_id in state.characters] + if not actor_ids: + return 0.5 + event_probes = _event_signal_tokens(event) + duty_type = str((state.current_chapter_task or {}).get("duty_type") or "") + if duty_type: + event_probes.append(duty_type) + event_probes.extend(_DUTY_ALIGNMENT_RULES.get(duty_type, {}).get("tags", [])) + scores: List[float] = [] + for actor_id in actor_ids: + character = state.characters[actor_id] + runtime_entry = _character_memory_payload(state, actor_id, world=world) + structured_memory = dict(runtime_entry.get("structured_memory") or {}) + card_probes = ( + list(character.public_goals[:3]) + + list(character.hidden_goals[:3]) + + list(character.vows.vows[:3]) + + [ + character.wound.core_wound, + character.wound.public_self, + character.wound.shadow_desire, + character.destiny.life_theme, + ] + + list(structured_memory.get("goals", [])) + + list(structured_memory.get("promises", [])) + + list(structured_memory.get("scars", [])) + + list(structured_memory.get("taboos", [])) + ) + filtered_probes = [str(item) for item in card_probes if str(item)] + scores.append(_keyword_overlap(filtered_probes, event_probes) if filtered_probes else 0.5) + return max(0.0, min(1.0, sum(scores) / float(len(scores)))) + + +def _duty_alignment(state: NarrativeState, event: EventAtom) -> float: + duty_type = str((state.current_chapter_task or {}).get("duty_type") or "") + if not duty_type: + return 0.5 + rules = _DUTY_ALIGNMENT_RULES.get(duty_type) + if not rules: + return 0.5 + scene_score = 1.0 if event.scene_function in rules["scene_functions"] else 0.0 + tag_score = _keyword_overlap(_event_signal_tokens(event), rules["tags"] + rules["scene_functions"]) + return max(0.0, min(1.0, 0.65 * scene_score + 0.35 * tag_score)) + + +def _actor_style_coverage(world: WorldBible, state: NarrativeState, actor_ids: Sequence[str]) -> float: + if not actor_ids: + return 0.5 + style_pack = style_pack_from_world(world) + voice_profiles = dict(style_pack.dialogue.voice_profiles or {}) + pressure_styles = dict(style_pack.dialogue.pressure_styles or {}) + covered = 0.0 + for actor_id in actor_ids: + role_key = getattr(state.characters.get(actor_id), "role", "") + if actor_id in voice_profiles or role_key in voice_profiles: + covered += 0.5 + if actor_id in pressure_styles or role_key in pressure_styles: + covered += 0.5 + return max(0.0, min(1.0, covered / float(len(actor_ids)))) + + +def _emotion_action_alignment( + state: NarrativeState, + event: EventAtom, + *, + world: Optional[WorldBible] = None, +) -> float: + if world is None: + return 0.5 + style_pack = style_pack_from_world(world) + action_pool = dict((style_pack.emotion_actions.action_map or {}).get(event.scene_function, {}) or {}) + slot_coverage = sum(1 for slot in ("entry", "pressure", "pivot", "aftermath", "echo") if action_pool.get(slot)) + normalized_slot_coverage = float(slot_coverage) / 5.0 if slot_coverage else 0.4 + actor_coverage = _actor_style_coverage(world, state, [actor_id for actor_id in event.actors if actor_id in state.characters]) + return max(0.0, min(1.0, 0.6 * normalized_slot_coverage + 0.4 * actor_coverage)) + + +def _terminal_before_late_penalty(state: NarrativeState, event: EventAtom) -> float: + progression = dict((state.metadata or {}).get("longform_progression") or {}) + target_chapters = int(progression.get("series_target_chapters", 0) or 0) + current_chapter = int(progression.get("series_chapter_index", state.chapter_index or 0) or state.chapter_index or 0) + completion_ratio = ( + float(current_chapter) / float(max(1, target_chapters)) + if target_chapters > 0 + else 0.0 + ) + if not is_terminal_scene_function(event.scene_function, event.metadata): + return 0.0 + if bool((state.metadata or {}).get("series_terminal_ready")): + return 0.0 + if completion_ratio < 0.8: + return 1.0 + if completion_ratio < 0.92: + return 0.8 + return 0.4 + + +def _scene_function_cluster_penalty(state: NarrativeState, event: EventAtom) -> float: + recent = [str(item) for item in list(state.recent_scene_functions or []) if str(item)] + if not recent: + return 0.0 + same_count = sum(1 for item in recent if item == event.scene_function) + if same_count == 0: + return 0.0 + return min(1.0, same_count / float(max(1, len(recent)))) + + +def _duty_cluster_penalty(state: NarrativeState) -> float: + duty_type = str((state.current_chapter_task or {}).get("duty_type") or "") + recent = [str(item) for item in list((state.metadata or {}).get("recent_duty_types") or []) if str(item)] + if not duty_type or not recent: + return 0.0 + same_count = sum(1 for item in recent if item == duty_type) + if same_count == 0: + return 0.0 + if duty_type in {"resolve_promise", "deliver_climax"}: + return min(1.0, 0.5 + (same_count / float(max(1, len(recent))))) + return min(1.0, same_count / float(max(1, len(recent)))) + + +def _continuation_pressure_bonus(state: NarrativeState, event: EventAtom) -> float: + quality_contract = dict((state.current_chapter_task or {}).get("quality_contract") or {}) + if not bool(quality_contract.get("continuation_pressure_required", False)): + return 0.0 + if is_terminal_scene_function(event.scene_function, event.metadata): + return 0.0 + continuation_scene_functions = { + "debt_exchange", + "karma_ripening", + "truth_trial", + "misrecognition", + "temptation", + "confession_window", + "mask_crack", + } + continuation_tags = {"truth", "love", "loyalty", "reputation", "destiny", "selfhood"} + if event.scene_function in continuation_scene_functions: + return 1.0 + if set(event.tags) & continuation_tags: + return 0.7 + return 0.2 + + +def _replan_debt_penalty(state: NarrativeState, event: EventAtom) -> float: + debt = active_replan_debt(state) + if not debt: + return 0.0 + issue_codes = {str(item) for item in debt.get("issue_codes", []) if str(item)} + penalty = 0.0 + if "Q09" in issue_codes and ( + is_terminal_scene_function(event.scene_function, event.metadata) + or event.scene_function in {"vow_payment", "karma_ripening"} + ): + penalty += 0.7 + if "Q07" in issue_codes and event.scene_function in {"false_peace", "vow_payment"}: + penalty += 0.3 + return min(1.0, penalty) + + +def _relationship_debt_bonus(state: NarrativeState, event: EventAtom) -> float: + debt = active_replan_debt(state) + if not debt: + return 0.0 + recovery_scene_functions = {"debt_exchange", "misrecognition", "truth_trial", "confession_window", "temptation"} + recovery_tags = {"love", "truth", "loyalty", "reputation", "sacrifice"} + if event.scene_function in recovery_scene_functions: + return 1.0 + if set(event.tags) & recovery_tags: + return 0.7 + return 0.0 + + def causal_consistency( state: NarrativeState, event: EventAtom, @@ -126,6 +359,25 @@ def score_event( resolved = (weights or SearchWeights()).normalized() causal = causal_consistency(state, event, world=world) actor_components = _aggregate_character_signals(state, event) + card_alignment = _character_card_alignment(state, event, world=world) + duty_alignment = _duty_alignment(state, event) + emotion_alignment = _emotion_action_alignment(state, event, world=world) + terminal_before_late_penalty = _terminal_before_late_penalty(state, event) + scene_function_cluster_penalty = _scene_function_cluster_penalty(state, event) + duty_cluster_penalty = _duty_cluster_penalty(state) + continuation_pressure_bonus = _continuation_pressure_bonus(state, event) + replan_debt_penalty = _replan_debt_penalty(state, event) + relationship_debt_bonus = _relationship_debt_bonus(state, event) + blended_character_fidelity = max( + 0.0, + min( + 1.0, + 0.72 * actor_components["character_fidelity"] + + 0.14 * card_alignment + + 0.08 * duty_alignment + + 0.06 * emotion_alignment, + ), + ) components = { "desire_pull": actor_components["desire_pull"], "shadow_pull": actor_components["shadow_pull"], @@ -136,7 +388,16 @@ def score_event( "karma_pull": actor_components["karma_pull"], "fate_pull": actor_components["fate_pull"], "wisdom_resistance": actor_components["wisdom_resistance"], - "character_fidelity": actor_components["character_fidelity"], + "character_fidelity": blended_character_fidelity, + "character_card_alignment": card_alignment, + "duty_alignment": duty_alignment, + "emotion_action_alignment": emotion_alignment, + "continuation_pressure_bonus": continuation_pressure_bonus, + "relationship_debt_bonus": relationship_debt_bonus, + "terminal_before_late_penalty": terminal_before_late_penalty, + "scene_function_cluster_penalty": scene_function_cluster_penalty, + "duty_cluster_penalty": duty_cluster_penalty, + "replan_debt_penalty": replan_debt_penalty, "causal_consistency": causal, "dramatic_tension_delta": dramatic_tension_delta(state, event), "thematic_resonance": thematic_resonance(state, event, world=world), @@ -152,6 +413,13 @@ def score_event( + resolved.fate_pull * components["fate_pull"] - resolved.wisdom_resistance * components["wisdom_resistance"] ) + if state.current_chapter_task: + total += 0.08 * card_alignment + 0.06 * duty_alignment + 0.04 * emotion_alignment + total += 0.05 * continuation_pressure_bonus + 0.04 * relationship_debt_bonus + total -= 0.08 * terminal_before_late_penalty + total -= 0.05 * scene_function_cluster_penalty + total -= 0.05 * duty_cluster_penalty + total -= 0.07 * replan_debt_penalty total = max(0.0, min(1.0, total)) * causal return ScoredCandidate( event=event, diff --git a/src/narrativeos/search.py b/src/narrativeos/search.py index 8890d9d..bc22ba8 100644 --- a/src/narrativeos/search.py +++ b/src/narrativeos/search.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from time import perf_counter from typing import Dict, List, Optional, Sequence, Tuple from .critics import BaseCritic, default_critics @@ -59,7 +60,9 @@ def evaluate_candidates( min_candidates: int = 6, max_candidates: int = 10, ) -> Tuple[CandidateBatch, List[ScoredCandidate]]: + total_started = perf_counter() critics = list(critics or default_critics()) + provider_started = perf_counter() candidate_batch = candidate_provider.generate( state, world, @@ -67,11 +70,15 @@ def evaluate_candidates( min_candidates=min_candidates, max_candidates=max_candidates, ) + provider_latency_ms = round((perf_counter() - provider_started) * 1000.0, 3) legal_candidates = list(candidate_batch.legal_candidates) + critics_started = perf_counter() decision_map = _critic_decisions_by_event(state, world, legal_candidates, critics) + critics_latency_ms = round((perf_counter() - critics_started) * 1000.0, 3) rejected_event_ids = [] scored_candidates: List[ScoredCandidate] = [] + scoring_started = perf_counter() for event in legal_candidates: decisions = decision_map.get(event.event_id, []) verdicts = [decision["verdict"] for decision in decisions] @@ -100,11 +107,27 @@ def evaluate_candidates( ) base.explanation = "%s; critics=%s" % (base.explanation, verdict_summary) scored_candidates.append(base) + scoring_latency_ms = round((perf_counter() - scoring_started) * 1000.0, 3) + sort_started = perf_counter() scored_candidates.sort( key=lambda candidate: (-candidate.total_score, candidate.event.event_id) ) + sort_latency_ms = round((perf_counter() - sort_started) * 1000.0, 3) candidate_batch.debug["critic_rejections"] = rejected_event_ids + candidate_batch.debug["candidate_counts"] = { + "raw": len(list(candidate_batch.raw_candidates or [])), + "legal": len(legal_candidates), + "scored": len(scored_candidates), + "critic_rejections": len(rejected_event_ids), + } + candidate_batch.debug["timing_ms"] = { + "provider": provider_latency_ms, + "critics": critics_latency_ms, + "scoring": scoring_latency_ms, + "sort": sort_latency_ms, + "total": round((perf_counter() - total_started) * 1000.0, 3), + } return candidate_batch, scored_candidates diff --git a/src/narrativeos/services/authoring.py b/src/narrativeos/services/authoring.py index 1e86ad2..269f299 100644 --- a/src/narrativeos/services/authoring.py +++ b/src/narrativeos/services/authoring.py @@ -9,18 +9,44 @@ from uuid import uuid4 from ..benchmark.runner import run_benchmark +from ..content_quality_contracts import ( + content_quality_window_metrics, + diagnostic_issue_codes_for_chapter_payload, + ensure_chapter_task_quality_contract, + ensure_scene_quality_contract, + issue_asset_target, + resolve_content_quality_contract, +) +from ..content_quality_strategy_execution import execute_strategy_bundle_protocol +from ..content_quality_strategy_bundles import build_strategy_bundle from ..core.linter import lint_chapter_draft from ..eval.learned_inference import LearnedInferenceService, default_learned_artifact_dir from ..eval.learned_shadow import LearnedShadowService from ..eval.reporting import aggregate_reports from ..eval.service import evaluate_chapter from ..eval.taxonomy import ISSUE_TAXONOMY +from ..longform import ( + configure_interactive_longform_runtime, + configure_longform_runtime, + evaluate_longform_gate, + apply_steering_directive, + record_replan_debt, +) from ..models import NarrativeState from ..persistence.repositories import SQLAlchemyPlatformRepository from ..pipeline import plan_next_turn from ..providers import StaticCandidateProvider from ..rendering import TemplateRenderer from .billing import BillingService +from .longform_capability import ( + band_minimums, + build_longform_capability_payload, + longform_structure_counts, + load_longform_capability_profiles, + quick_brief_max_target_chapters, + sync_longform_capability_metadata, + target_band_for_chapters, +) from .observability import ObservabilityService from .provider_routing import ProviderRoutingService from .training_signal import TrainingSignalService @@ -28,6 +54,113 @@ from ..worldpacks.registry import FileSystemWorldRegistry from ..worldpacks.validator import validate_worldpack_payload +LONGFORM_CAPABILITY_BAND_ORDER = ("100", "250", "500", "1000") +DEFAULT_LONGFORM_CAPABILITY_PROFILES = { + "quick_brief_max_target_chapters": 100, + "structured_longform_bands": ["250", "500", "1000"], + "bands": { + "100": {"min_characters": 8, "min_scene_blueprints": 8, "min_locations": 6}, + "250": {"min_characters": 12, "min_scene_blueprints": 12, "min_locations": 8}, + "500": {"min_characters": 16, "min_scene_blueprints": 16, "min_locations": 12}, + "1000": {"min_characters": 24, "min_scene_blueprints": 24, "min_locations": 16}, + }, +} +LONGFORM_EXTRA_NAME_COMPONENTS = { + "jade_court": { + "surnames": ["沈", "顾", "谢", "韩", "柳", "裴", "周", "季", "宁", "苏", "陆", "程"], + "givens": ["明漪", "无尘", "持正", "观澜", "知微", "照雪", "怀瑾", "停云", "回霜", "闻秋", "静姝", "见山"], + }, + "urban_mystery": { + "surnames": ["林", "周", "许", "宋", "陈", "顾", "沈", "程", "苏", "袁", "裴", "方"], + "givens": ["知夏", "予安", "闻笙", "照晚", "景澄", "遥川", "宁秋", "亦岚", "清禾", "向晚", "以棠", "见鹿"], + }, + "xianxia": { + "surnames": ["顾", "谢", "韩", "柳", "白", "商", "宁", "裴", "季", "岑", "秦", "温"], + "givens": ["明漪", "无尘", "持正", "观澜", "栖", "寄寒", "照影", "知岸", "孤云", "夜舟", "停雪", "望舒"], + }, + "synthetic": { + "surnames": ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸", "子", "丑"], + "givens": ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"], + }, +} +LONGFORM_EXTRA_LOCATION_POOLS = { + "jade_court": ["偏厅", "暖阁", "祠堂前庭", "侧门回廊", "马车前", "花窗下", "池畔", "佛堂", "后院石径", "雨廊"], + "urban_mystery": ["旧仓库", "沿江堤岸", "出租屋楼道", "停车场边角", "地下通道", "医院走廊", "夜班食堂", "地铁换乘口", "江边栏杆", "旧商场顶层"], + "xianxia": ["藏经阁", "洗剑池", "断桥", "山腰栈道", "药庐", "后山碑林", "渡口舟棚", "禁地石门", "云海台", "观星崖", "寒潭边", "钟楼"], + "synthetic": ["门前", "石桌旁", "桥下", "楼梯口", "长凳边", "屋檐下", "院墙旁", "空廊尽头"], +} +LONGFORM_EXTRA_ROLE_CYCLE = [ + "supporting", + "mentor", + "rival", + "observer", + "guardian", + "outsider", + "scholar", + "messenger", +] +LONGFORM_EXTRA_WOUND_POOL = [ + "总在残局里替别人收尾,却没人替自己留退路", + "被更大的秩序拿走过一次最重要的选择", + "曾经说迟了一句真话,从此总怕再晚一步", + "习惯先看全局,结果总把自己留在代价最后", +] +LONGFORM_EXTRA_PUBLIC_SELF_POOL = [ + "我先稳住局势", + "我只是来把事情看清", + "我只讲分寸,不讲私心", + "先把眼前这一层压住再说", +] +LONGFORM_EXTRA_SHADOW_DESIRE_POOL = [ + "有人也替我考虑一次", + "被平等地选一次", + "能在不失去彼此的前提下把真话说完", + "不再总在局势外面看着别人做决定", +] +LONGFORM_EXTRA_VOW_POOL = [ + "不再把最重的一句拖到下一次再说", + "先把真相看清,再决定站在哪一边", + "这一次不替任何人把后果遮回去", + "如果代价一定要落下,也要先认清它落在谁身上", +] +LONGFORM_SCENE_TEMPLATE_CATALOG = [ + ("threshold_watch", "setup", ["入场观察", "气压收紧", "旧事回响", "留下钩子"]), + ("public_pressure", "trust_test", ["旁人施压", "关系试探", "局面转紧", "收尾余波"]), + ("private_reckoning", "discovery", ["私下对峙", "旧话翻出", "代价显形", "留下未尽之意"]), + ("misread_crossfire", "reversal", ["误会升级", "嘴硬回避", "裂口扩大", "各退半步"]), + ("oath_echo", "temptation", ["旧誓回响", "心意动摇", "后果逼近", "强行压下"]), + ("debt_collection", "trust_test", ["旧债追上来", "关系重新算账", "局势翻转", "留下一层新亏欠"]), + ("false_peace", "setup", ["表面稳住", "暗流未散", "细节露口", "下一步更难回避"]), + ("choice_corridor", "discovery", ["逼近选择", "各执一词", "无人先退", "后续分岔"]), + ("world_pressure", "setup", ["外部异变", "规则压下", "人物应对", "留下更大问题"]), + ("alliance_test", "trust_test", ["短暂联手", "分歧暴露", "关系变调", "留下隐患"]), + ("memory_return", "discovery", ["旧记忆回返", "真相补全", "情绪反噬", "重写关系站位"]), + ("trial_window", "temptation", ["试探底线", "代价浮起", "有人动摇", "悬而未决"]), + ("witness_break", "reversal", ["旁证出现", "叙事翻面", "旧判断失效", "连锁后果启动"]), + ("aftershock_room", "reversal", ["余波扩散", "沉默堆高", "关系错位", "逼出后续动作"]), + ("pursuit_edge", "setup", ["追索线索", "边缘逼近", "风险抬升", "留下更重悬念"]), + ("faction_barter", "trust_test", ["势力交换", "条件抬价", "人物拉扯", "留下账目"]), + ("threshold_gate", "setup", ["来到门槛前", "旧规则再现", "退路收窄", "必须表态"]), + ("confession_pivot", "discovery", ["一句真话落地", "关系偏转", "代价重新分配", "下一章继续追上"]), + ("fracture_walk", "reversal", ["并肩不再同路", "旧默契断裂", "余波扩大", "后续更难和解"]), + ("archive_dig", "discovery", ["翻旧档案", "补齐因果", "多出新的空白", "把人推回主线"]), + ("return_to_scene", "setup", ["重回旧地", "细节复现", "关系失衡", "留下更大问号"]), + ("cliff_ledger", "temptation", ["代价盘点", "选择逼近", "无人能免", "把结尾钩子压实"]), + ("signal_transfer", "setup", ["消息转手", "误差扩大", "立场偏移", "下一幕更危险"]), + ("night_bridge", "discovery", ["深夜重逢", "误会变形", "心意露口", "留下回身索账"]), +] +INTERACTIVE_WINDOW_ISSUE_CODES = ("Q03", "Q04", "Q05", "Q09") + + +def _percentile(values: List[float], quantile: float) -> float: + cleaned = sorted(float(item) for item in values if item is not None) + if not cleaned: + return 0.0 + if len(cleaned) == 1: + return round(cleaned[0], 3) + index = max(0, min(len(cleaned) - 1, int(round((len(cleaned) - 1) * float(quantile))))) + return round(cleaned[index], 3) + class AuthoringService: def __init__( @@ -53,13 +186,285 @@ def __init__( ) self.provider_routing = provider_routing_service self.observability = observability_service + self.promise_editor_states = [ + "watch", + "defer", + "plan_payoff", + "resolved_intentional", + "escalate", + ] + self.continuity_override_states = [ + "watch", + "intentional", + "accepted_tradeoff", + "needs_rewrite", + "escalate", + ] + self._longform_capability_profiles_cache: Optional[Dict[str, Any]] = None - def _normalize_change_context(self, change_context: Optional[Dict[str, Any]], *, default_source: str, default_label: str) -> Dict[str, str]: - payload = dict(change_context or {}) + def _longform_capability_profiles(self) -> Dict[str, Any]: + if self._longform_capability_profiles_cache is None: + self._longform_capability_profiles_cache = load_longform_capability_profiles(self.base_dir) + return dict(self._longform_capability_profiles_cache) + + def _target_band_for_chapters(self, target_total_chapters: int) -> str: + return target_band_for_chapters(target_total_chapters) + + def _band_rank(self, band: Optional[str]) -> int: + normalized = str(band or "").strip() + if normalized not in LONGFORM_CAPABILITY_BAND_ORDER: + return -1 + return LONGFORM_CAPABILITY_BAND_ORDER.index(normalized) + + def _band_minimums(self, band: str) -> Dict[str, int]: + return band_minimums(self._longform_capability_profiles(), band) + + def _quick_brief_max_target_chapters(self) -> int: + return quick_brief_max_target_chapters(self._longform_capability_profiles()) + + def _longform_entry_mode(self, metadata: Dict[str, Any]) -> str: + stored = str(metadata.get("entry_mode") or "").strip() + if stored: + return stored + if metadata.get("generated_from_brief"): + return "quick_brief" + return "structured_longform" + + def _longform_structure_counts(self, worldpack_payload: Dict[str, Any]) -> Dict[str, int]: + return longform_structure_counts(worldpack_payload) + + def _supported_target_band(self, *, counts: Dict[str, int], entry_mode: str) -> Optional[str]: + highest: Optional[str] = None + for band in LONGFORM_CAPABILITY_BAND_ORDER: + minimums = self._band_minimums(band) + if ( + counts["character_count"] >= minimums["min_characters"] + and counts["scene_blueprint_count"] >= minimums["min_scene_blueprints"] + and counts["location_count"] >= minimums["min_locations"] + ): + highest = band + if highest is None: + return None + if entry_mode == "quick_brief" and self._band_rank(highest) > self._band_rank("100"): + return "100" + return highest + + def _extract_open_promises_from_issue(self, issue: Dict[str, Any]) -> Optional[int]: + for evidence in list(issue.get("evidence") or []): + text = str(evidence or "") + if text.startswith("open_promises="): + try: + return int(text.split("=", 1)[1]) + except ValueError: + return None + return None + + def _latest_longform_runway_guard(self, version: WorldVersion) -> Optional[Dict[str, Any]]: + brief = dict(((version.worldpack_json or {}).get("metadata") or {}).get("author_brief") or {}) + target_total_chapters = max(1, int(brief.get("target_total_chapters") or ((version.worldpack_json or {}).get("series_plan") or {}).get("total_chapter_target") or 100)) + if target_total_chapters < 100: + return None + works = self.repository.list_author_works(account_id=version.author_id, world_version_id=version.world_version_id, limit=20) + if not works: + return None + active_work = next((item for item in works if item.get("is_active_line")), None) or works[0] + revisions = self.repository.list_author_work_revisions(work_id=active_work["work_id"], limit=20) + blocked_revision = next((item for item in revisions if item.get("revision_type") == "quality_guard_blocked"), None) + if not blocked_revision: + return None + snapshot = dict(blocked_revision.get("snapshot_json") or {}) + quality_gate = dict(snapshot.get("quality_gate") or {}) + issues = [dict(item or {}) for item in list(quality_gate.get("issues") or [])] + issue_codes = {str(item.get("issue_code") or "").strip() for item in issues if str(item.get("issue_code") or "").strip()} + if "Q09" not in issue_codes: + return None + chapter_index = int(snapshot.get("chapter_index") or 0) + if chapter_index <= 0 or chapter_index >= int(target_total_chapters * 0.8): + return None + open_promises = None + for issue in issues: + open_promises = self._extract_open_promises_from_issue(issue) + if open_promises is not None: + break + if open_promises is None or open_promises > 0: + return None + return { + "key": "longform_structure_exhaustion", + "severity": "high", + "message": f"当前长线在第 {chapter_index} 章附近出现续航耗空信号:开放 promises 已归零,继续盲跑更容易触发节奏塌陷。", + "chapter_index": chapter_index, + "work_id": active_work.get("work_id"), + "pacing": dict(quality_gate.get("scores") or {}).get("pacing"), + "issue_codes": sorted(issue_codes), + "recommended_actions": [ + "bootstrap_structured_longform", + "expand_character_and_scene_lattice", + "rebuild_promise_lattice", + ], + } + + def _build_longform_capability_payload( + self, + *, + worldpack_payload: Dict[str, Any], + version: Optional[WorldVersion] = None, + ) -> Dict[str, Any]: + return build_longform_capability_payload( + base_dir=self.base_dir, + repository=self.repository, + worldpack_payload=worldpack_payload, + version=version, + ) + + def _sync_longform_capability_metadata(self, worldpack_payload: Dict[str, Any], *, version: Optional[WorldVersion] = None) -> Dict[str, Any]: + return sync_longform_capability_metadata( + base_dir=self.base_dir, + repository=self.repository, + worldpack_payload=worldpack_payload, + version=version, + ) + + def _apply_longform_asset_enrichment( + self, + *, + worldpack_payload: Dict[str, Any], + target_band: str, + ) -> None: + minimums = self._band_minimums(target_band) + metadata = self._ensure_metadata(worldpack_payload) + brief = dict(metadata.get("author_brief") or {}) + preset_id = str(brief.get("genre_preset") or "urban_mystery") + life_theme = str( + brief.get("life_theme") + or ((worldpack_payload.get("series_plan") or {}).get("theme_statement") or "") + or ((worldpack_payload.get("title") or "长篇故事")) + ) + existing_characters = [dict(item) for item in list(worldpack_payload.get("characters") or [])] + worldpack_payload["characters"] = existing_characters + _next_longform_character_blueprints( + preset_id=preset_id, + life_theme=life_theme, + existing_character_ids=[str(item.get("character_id") or "") for item in existing_characters], + current_count=len(existing_characters), + target_count=minimums["min_characters"], + ) + target_scene_count = max( + minimums["min_scene_blueprints"], + minimums.get("min_scene_family_count", 0), + minimums.get("min_distinct_role_pairs", 0), + ) + character_ids = [str(item.get("character_id") or "") for item in worldpack_payload.get("characters") or [] if str(item.get("character_id") or "").strip()] + next_scenes = _next_longform_scene_blueprints( + preset_id=preset_id, + existing_scenes=list(worldpack_payload.get("scene_blueprints") or []), + character_ids=character_ids, + target_count=target_scene_count, + desired_scene_family_count=minimums.get("min_scene_family_count", 0), + desired_role_pair_count=minimums.get("min_distinct_role_pairs", 0), + ) + while True: + probe_payload = { + **worldpack_payload, + "scene_blueprints": next_scenes, + } + counts = self._longform_structure_counts(probe_payload) + if ( + counts["scene_blueprint_count"] >= minimums["min_scene_blueprints"] + and counts["scene_family_count"] >= minimums.get("min_scene_family_count", 0) + and counts["distinct_role_pair_count"] >= minimums.get("min_distinct_role_pairs", 0) + ): + break + next_scenes = _next_longform_scene_blueprints( + preset_id=preset_id, + existing_scenes=next_scenes, + character_ids=character_ids, + target_count=len(next_scenes) + 1, + desired_scene_family_count=minimums.get("min_scene_family_count", 0), + desired_role_pair_count=minimums.get("min_distinct_role_pairs", 0), + ) + worldpack_payload["scene_blueprints"] = next_scenes + world_bible = dict(worldpack_payload.get("world_bible") or {}) + world_bible["locations"] = _next_longform_locations( + preset_id=preset_id, + existing_locations=list(world_bible.get("locations") or []), + target_count=minimums["min_locations"], + ) + worldpack_payload["world_bible"] = world_bible + worldpack_payload["sensory_grounding_policies"] = _build_sensory_policies_for_preset(preset_id, list(world_bible.get("locations") or [])) + _ensure_character_asset_coverage(worldpack_payload, preset_id=preset_id) + metadata["longform_asset_enrichment"] = { + "band": target_band, + "character_count": len(list(worldpack_payload.get("characters") or [])), + "scene_blueprint_count": len(list(worldpack_payload.get("scene_blueprints") or [])), + "location_count": len(list((worldpack_payload.get("world_bible") or {}).get("locations") or [])), + } + + def _should_persist_longform_capability_metadata(self, worldpack_payload: Dict[str, Any]) -> bool: + metadata = dict(worldpack_payload.get("metadata") or {}) + return bool( + metadata.get("author_brief") + or metadata.get("generated_from_brief") + or metadata.get("entry_mode") + or metadata.get("requested_target_chapters") + or metadata.get("claim_safe_band") + or metadata.get("longform_readiness") + or metadata.get("longform_workbench_bootstrapped") + ) + + def _normalize_repair_loop_context(self, payload: Optional[Dict[str, Any]]) -> Dict[str, Any]: + context = dict(payload or {}) + targeted_chapters = [] + for item in context.get("targeted_chapters", []) or []: + chapter = dict(item or {}) + chapter_index = int(chapter.get("chapter_index", 0) or 0) + if chapter_index <= 0: + continue + targeted_chapters.append( + { + "chapter_index": chapter_index, + "chapter_title": str(chapter.get("chapter_title") or ""), + } + ) return { + "issue_code": str(context.get("issue_code") or "").strip(), + "issue_label": str(context.get("issue_label") or "").strip(), + "asset_type": str(context.get("asset_type") or "").strip(), + "asset_label": str(context.get("asset_label") or "").strip(), + "target_label": str(context.get("target_label") or "").strip(), + "validation_panel": str(context.get("validation_panel") or "").strip(), + "validation_panel_label": str(context.get("validation_panel_label") or "").strip(), + "validation_reason": str(context.get("validation_reason") or "").strip(), + "character_id": str(context.get("character_id") or "").strip(), + "scene_id": str(context.get("scene_id") or "").strip(), + "scene_function": str(context.get("scene_function") or "").strip(), + "chapter_task_id": str(context.get("chapter_task_id") or "").strip(), + "arc_id": str(context.get("arc_id") or "").strip(), + "volume_id": str(context.get("volume_id") or "").strip(), + "chapter_index": int(context.get("chapter_index", 0) or 0) or None, + "chapter_title": str(context.get("chapter_title") or "").strip(), + "window_label": str(context.get("window_label") or "").strip(), + "window_range_start": int(context.get("window_range_start", 0) or 0) or None, + "window_range_end": int(context.get("window_range_end", 0) or 0) or None, + "window_breach_kind": str(context.get("window_breach_kind") or "").strip(), + "baseline_issue_count": int(context.get("baseline_issue_count", 0) or 0), + "baseline_worst_decision": str(context.get("baseline_worst_decision") or "").strip(), + "contract_failed_checks": [str(item) for item in context.get("contract_failed_checks", []) if str(item)], + "targeted_chapters": targeted_chapters, + "targeted_chapter_indices": ( + [int(item) for item in context.get("targeted_chapter_indices", []) if int(item or 0) > 0] + or [item["chapter_index"] for item in targeted_chapters] + ), + } + + def _normalize_change_context(self, change_context: Optional[Dict[str, Any]], *, default_source: str, default_label: str) -> Dict[str, Any]: + payload = dict(change_context or {}) + normalized = { "source": str(payload.get("source") or default_source), "label": str(payload.get("label") or default_label), } + repair_loop_context = self._normalize_repair_loop_context(payload.get("repair_loop_context")) + if any(repair_loop_context.values()): + normalized["repair_loop_context"] = repair_loop_context + return normalized def _revision_history(self, worldpack_payload: Dict[str, Any]) -> List[Dict[str, Any]]: return list((worldpack_payload.get("metadata") or {}).get("revision_history", [])) @@ -69,6 +474,90 @@ def _ensure_metadata(self, worldpack_payload: Dict[str, Any]) -> Dict[str, Any]: worldpack_payload["metadata"] = metadata return metadata + def _promise_state_overrides(self, metadata: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + workbench = dict(metadata.get("longform_workbench", {}) or {}) + raw_overrides = dict(workbench.get("promise_state_overrides", {}) or {}) + normalized: Dict[str, Dict[str, Any]] = {} + for promise_id, payload in raw_overrides.items(): + if not str(promise_id): + continue + normalized[str(promise_id)] = dict(payload or {}) + return normalized + + def _set_promise_state_override( + self, + metadata: Dict[str, Any], + *, + promise_id: str, + editor_state: str, + notes: str = "", + chapter_index: Optional[int] = None, + chapter_task_id: Optional[str] = None, + arc_id: Optional[str] = None, + volume_id: Optional[str] = None, + ) -> None: + workbench = dict(metadata.get("longform_workbench", {}) or {}) + overrides = dict(workbench.get("promise_state_overrides", {}) or {}) + state_value = str(editor_state or "").strip() + note_value = str(notes or "").strip() + if not state_value and not note_value: + overrides.pop(promise_id, None) + else: + overrides[promise_id] = { + "editor_state": state_value, + "notes": note_value, + "updated_at": datetime.now(timezone.utc).isoformat(), + "chapter_index": int(chapter_index) if chapter_index else None, + "chapter_task_id": chapter_task_id, + "arc_id": arc_id, + "volume_id": volume_id, + } + workbench["promise_state_overrides"] = overrides + metadata["longform_workbench"] = workbench + + def _continuity_overrides(self, metadata: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + workbench = dict(metadata.get("longform_workbench", {}) or {}) + raw_overrides = dict(workbench.get("continuity_overrides", {}) or {}) + normalized: Dict[str, Dict[str, Any]] = {} + for chapter_key, payload in raw_overrides.items(): + if not str(chapter_key): + continue + normalized[str(chapter_key)] = dict(payload or {}) + return normalized + + def _set_continuity_override( + self, + metadata: Dict[str, Any], + *, + chapter_index: int, + override_state: str, + notes: str = "", + issue_scope: Optional[List[str]] = None, + chapter_task_id: Optional[str] = None, + arc_id: Optional[str] = None, + volume_id: Optional[str] = None, + ) -> None: + workbench = dict(metadata.get("longform_workbench", {}) or {}) + overrides = dict(workbench.get("continuity_overrides", {}) or {}) + chapter_key = str(int(chapter_index)) + state_value = str(override_state or "").strip() + note_value = str(notes or "").strip() + scope_values = [str(item).strip() for item in (issue_scope or []) if str(item).strip()] + if not state_value and not note_value and not scope_values: + overrides.pop(chapter_key, None) + else: + overrides[chapter_key] = { + "override_state": state_value, + "notes": note_value, + "issue_scope": scope_values, + "updated_at": datetime.now(timezone.utc).isoformat(), + "chapter_task_id": chapter_task_id, + "arc_id": arc_id, + "volume_id": volume_id, + } + workbench["continuity_overrides"] = overrides + metadata["longform_workbench"] = workbench + def _snapshot_summary(self, snapshot: Dict[str, Any]) -> str: characters = len(snapshot.get("characters", [])) scenes = len(snapshot.get("scene_blueprints", [])) @@ -160,7 +649,7 @@ def _append_revision( self, *, worldpack_payload: Dict[str, Any], - change_context: Dict[str, str], + change_context: Dict[str, Any], diff_summary: Dict[str, Any], simulation_delta: Optional[Dict[str, Any]] = None, ) -> None: @@ -172,11 +661,13 @@ def _append_revision( "created_at": datetime.now(timezone.utc).isoformat(), "source": change_context["source"], "label": change_context["label"], + "change_context": copy.deepcopy(change_context), "summary": diff_summary["summary_text"], "changed_sections": list(diff_summary.get("changed_sections", [])), "diff_summary": copy.deepcopy(diff_summary), "worldpack_snapshot": copy.deepcopy(worldpack_payload), "simulation_delta": dict(simulation_delta or {}), + "repair_loop_context": copy.deepcopy(change_context.get("repair_loop_context") or {}), } ) metadata["revision_history"] = revision_history[-10:] @@ -451,26 +942,3913 @@ def _build_simulation_drilldown(self, simulation_report: Dict[str, Any]) -> Dict "next_actions": list((simulation_report.get("evaluation_summary") or {}).get("next_actions", [])), } - def _decorate_draft_payload(self, version: WorldVersion) -> dict[str, Any]: - metadata = dict((version.worldpack_json or {}).get("metadata", {})) + def _build_longform_drilldown(self, simulation_report: Dict[str, Any]) -> Dict[str, Any]: + if not simulation_report: + return {} + longform_summary = dict(simulation_report.get("longform_summary") or {}) + plan_snapshot = dict(simulation_report.get("longform_plan_snapshot") or {}) + series_plan = dict(plan_snapshot.get("series_plan") or {}) + volume_plans = sorted( + [dict(item) for item in plan_snapshot.get("volume_plans", [])], + key=lambda item: int(item.get("order", 0)), + ) + arc_plans = sorted( + [dict(item) for item in plan_snapshot.get("arc_plans", [])], + key=lambda item: (str(item.get("volume_id") or ""), int(item.get("order", 0))), + ) + chapter_trace = [dict(item) for item in simulation_report.get("chapter_trace", [])] + volume_map: Dict[str, List[Dict[str, Any]]] = {} + arc_map: Dict[str, List[Dict[str, Any]]] = {} + duty_histogram: Dict[str, int] = {} + ending_gate_blocks = 0 + fallback_chapters = 0 + for item in chapter_trace: + volume_id = str(item.get("volume_id") or "") + arc_id = str(item.get("arc_id") or "") + chapter_task = dict(item.get("chapter_task") or {}) + execution = dict(item.get("chapter_task_execution_summary") or {}) + if volume_id: + volume_map.setdefault(volume_id, []).append(item) + if arc_id: + arc_map.setdefault(arc_id, []).append(item) + duty = str(chapter_task.get("duty_type") or "") + if duty: + duty_histogram[duty] = duty_histogram.get(duty, 0) + 1 + if bool(execution.get("ending_gate_blocked", False)): + ending_gate_blocks += 1 + if bool(execution.get("used_fallback", False)): + fallback_chapters += 1 + volume_progress = [] + for volume in volume_plans: + volume_id = str(volume.get("volume_id") or "") + chapters = volume_map.get(volume_id, []) + target = max(1, int(volume.get("target_chapters", 1))) + completion_ratio = round(len(chapters) / float(target), 3) + volume_progress.append( + { + "volume_id": volume_id, + "title": volume.get("title"), + "order": int(volume.get("order", 0)), + "target_chapters": target, + "completed_chapters": len(chapters), + "completion_ratio": completion_ratio, + "status": "completed" if len(chapters) >= target else ("in_progress" if chapters else "pending"), + "first_chapter": chapters[0].get("chapter_id") if chapters else None, + "last_chapter": chapters[-1].get("chapter_id") if chapters else None, + "dominant_duty": max( + ( + (duty, sum(1 for chapter in chapters if (chapter.get("chapter_task") or {}).get("duty_type") == duty)) + for duty in {str((chapter.get("chapter_task") or {}).get("duty_type") or "") for chapter in chapters} + if duty + ), + default=(None, 0), + key=lambda item: item[1], + )[0], + } + ) + arc_progress = [] + for arc in arc_plans: + arc_id = str(arc.get("arc_id") or "") + chapters = arc_map.get(arc_id, []) + target = max(1, int(arc.get("target_chapters", 1))) + avg_score = 0.0 + if chapters: + scored = [ + float((chapter.get("evaluation") or {}).get("overall_score", 0.0)) + for chapter in chapters + if chapter.get("evaluation") + ] + if scored: + avg_score = round(sum(scored) / float(len(scored)), 3) + arc_progress.append( + { + "arc_id": arc_id, + "volume_id": arc.get("volume_id"), + "title": arc.get("title"), + "order": int(arc.get("order", 0)), + "target_chapters": target, + "completed_chapters": len(chapters), + "completion_ratio": round(len(chapters) / float(target), 3), + "status": "completed" if len(chapters) >= target else ("in_progress" if chapters else "pending"), + "average_score": avg_score, + "duty_histogram": [ + { + "duty_type": duty, + "count": sum(1 for chapter in chapters if (chapter.get("chapter_task") or {}).get("duty_type") == duty), + } + for duty in sorted( + { + str((chapter.get("chapter_task") or {}).get("duty_type") or "") + for chapter in chapters + if (chapter.get("chapter_task") or {}).get("duty_type") + } + ) + ], + } + ) + weakest_arcs = sorted( + [item for item in arc_progress if item.get("completed_chapters")], + key=lambda item: ( + float(item.get("average_score", 0.0)), + float(item.get("completion_ratio", 0.0)), + ), + )[:3] + gate = dict(simulation_report.get("longform_gate") or {}) + promise_runway_summary = self._build_promise_runway_summary(simulation_report) + midrun_signal_window = self._build_longform_midrun_signal_window(simulation_report) + structure_exhaustion = None + target_chapters = int(series_plan.get("total_chapter_target", longform_summary.get("target_chapters", 0) or 0)) + completed_chapters = int(simulation_report.get("completed_chapters", 0)) + top_issue_codes = { + str(item.get("issue_code") or "") + for item in list((simulation_report.get("evaluation_summary") or {}).get("top_issue_categories") or []) + if str(item.get("issue_code") or "") + } + trigger_issue_codes = sorted( + { + issue_code + for issue_code in (top_issue_codes | set(midrun_signal_window.get("issue_codes") or [])) + if issue_code in {"Q04", "Q09"} + } + ) + runway_exhausted = ( + promise_runway_summary.get("runway_status") == "exhausted" + or int(midrun_signal_window.get("open_promises_at_end", 0) or 0) <= 0 + ) + weak_midrun_shape = ( + float(midrun_signal_window.get("avg_pacing", 1.0) or 1.0) < 0.34 + or float(midrun_signal_window.get("avg_hook_quality", 1.0) or 1.0) < 0.5 + or float(midrun_signal_window.get("avg_exposition_ratio", 0.0) or 0.0) > 0.5 + or float(midrun_signal_window.get("scene_family_repeat_ratio", 0.0) or 0.0) > 0.45 + ) + if ( + target_chapters >= 100 + and completed_chapters < int(target_chapters * 0.8) + and runway_exhausted + and weak_midrun_shape + and trigger_issue_codes + ): + structure_exhaustion = { + "key": "longform_structure_exhaustion", + "status": "blocked", + "message": ( + f"当前长线在第 {completed_chapters} 章附近出现 promise runway 耗空," + "同时 pacing / hook / exposition 已进入中段塌陷窗口。" + ), + "trigger_issue_codes": trigger_issue_codes, + "signal_window": midrun_signal_window, + "recommended_actions": [ + "bootstrap_structured_longform", + "expand_character_and_scene_lattice", + "rebuild_promise_lattice", + ], + } return { - "world_version_id": version.world_version_id, - "world_id": version.world_id, - "status": version.status, - "worldpack": version.worldpack_json, - "validation_report": version.validation_report_json, - "validation_drilldown": self._build_validation_drilldown(dict(version.validation_report_json or {})), - "simulation_report": version.simulation_report_json, - "revision_history": list(metadata.get("revision_history", [])), - "latest_diff_summary": dict(metadata.get("latest_diff_summary", {})), - "diff_drilldown": { - **self._build_diff_drilldown(metadata), - "simulation_freshness": self._simulation_freshness(metadata, dict(version.simulation_report_json or {})), + "series_id": series_plan.get("series_id") or longform_summary.get("series_id"), + "series_title": series_plan.get("title"), + "target_chapters": target_chapters, + "completed_chapters": completed_chapters, + "volume_progress": volume_progress, + "arc_progress": arc_progress, + "weakest_arcs": weakest_arcs, + "duty_histogram": [ + {"duty_type": duty, "count": count} + for duty, count in sorted(duty_histogram.items(), key=lambda item: (-item[1], item[0])) + ], + "ending_gate_blocks": ending_gate_blocks, + "fallback_chapters": fallback_chapters, + "gate_status": gate.get("status"), + "gate_failed_checks": list(gate.get("failed_checks", [])), + "promise_runway_summary": promise_runway_summary, + "runway_status": promise_runway_summary.get("runway_status"), + "midrun_signal_window": midrun_signal_window, + "longform_structure_exhaustion": structure_exhaustion, + "next_actions": [ + f"repair_{name}" + for name in gate.get("failed_checks", []) + ] or (structure_exhaustion.get("recommended_actions", []) if structure_exhaustion else ["continue_longform_simulation"]), + } + + def _build_promise_ledger_workbench(self, simulation_report: Dict[str, Any]) -> Dict[str, Any]: + if not simulation_report: + return {} + final_state = dict(simulation_report.get("final_state_snapshot") or {}) + open_promises = [dict(item) for item in final_state.get("open_promises", [])] + state_metadata = dict(final_state.get("metadata") or {}) + closed_promise_ids = [str(item) for item in state_metadata.get("closed_promise_ids", []) if str(item)] + chapter_trace = [dict(item) for item in simulation_report.get("chapter_trace", [])] + first_seen: Dict[str, int] = {} + last_seen: Dict[str, int] = {} + for index, item in enumerate(chapter_trace, start=1): + for promise_id in item.get("open_promise_ids", []) or []: + if promise_id not in first_seen: + first_seen[promise_id] = index + last_seen[promise_id] = index + current_turn = int(final_state.get("turn_index", simulation_report.get("completed_chapters", 0)) or 0) + open_items = [] + overdue_count = 0 + for promise in open_promises: + due_by_turn = int(promise.get("due_by_turn", current_turn) or current_turn) + is_overdue = due_by_turn <= current_turn + if is_overdue: + overdue_count += 1 + promise_id = str(promise.get("promise_id") or "") + open_items.append( + { + "promise_id": promise_id, + "description": promise.get("description", ""), + "holders": list(promise.get("holders", [])), + "stakes": promise.get("stakes"), + "status": promise.get("status"), + "opened_at_turn": promise.get("opened_at_turn"), + "due_by_turn": promise.get("due_by_turn"), + "is_overdue": is_overdue, + "first_seen_chapter": first_seen.get(promise_id), + "last_seen_chapter": last_seen.get(promise_id), + "anchor": { + "anchor_type": "simulation", + "anchor_key": str(last_seen.get(promise_id) or first_seen.get(promise_id) or simulation_report.get("completed_chapters", 0)), + }, + } + ) + return { + "available": True, + "status": "active" if open_items else "clear", + "open_count": len(open_items), + "closed_count": len(closed_promise_ids), + "overdue_count": overdue_count, + "open_promises": open_items, + "recently_closed_ids": closed_promise_ids[-10:], + "next_actions": ( + ["comment_or_fix_overdue_promises"] if overdue_count else (["review_open_promises"] if open_items else ["continue_longform_simulation"]) + ), + } + + def _build_promise_runway_summary(self, simulation_report: Dict[str, Any]) -> Dict[str, Any]: + ledger = self._build_promise_ledger_workbench(simulation_report) + if not ledger: + return {} + chapter_trace = self._chapter_trace_with_promise_deltas(simulation_report) + current_turn = int(dict(simulation_report.get("final_state_snapshot") or {}).get("turn_index", simulation_report.get("completed_chapters", 0)) or 0) + chapters_with_new_promises = [ + int(item.get("simulation_chapter_index", 0) or 0) + for item in chapter_trace + if list(item.get("open_promise_ids", []) or []) + ] + last_new_promise_chapter = chapters_with_new_promises[-1] if chapters_with_new_promises else None + chapters_since_last_new_promise = ( + max(0, current_turn - int(last_new_promise_chapter)) + if last_new_promise_chapter is not None + else current_turn + ) + due_values = [ + int(item.get("due_by_turn", 0) or 0) + for item in ledger.get("open_promises", []) + if int(item.get("due_by_turn", 0) or 0) > 0 + ] + chapters_until_next_due_cluster = ( + max(0, min(due_values) - current_turn) + if due_values + else None + ) + open_count = int(ledger.get("open_count", 0) or 0) + overdue_count = int(ledger.get("overdue_count", 0) or 0) + if open_count <= 0: + runway_status = "exhausted" + elif overdue_count > 0 or chapters_since_last_new_promise >= 6 or open_count < 2: + runway_status = "thinning" + else: + runway_status = "healthy" + return { + "available": True, + "open_count": open_count, + "overdue_count": overdue_count, + "chapters_since_last_new_promise": chapters_since_last_new_promise, + "chapters_until_next_due_cluster": chapters_until_next_due_cluster, + "runway_status": runway_status, + "last_new_promise_chapter": last_new_promise_chapter, + } + + def _build_longform_midrun_signal_window( + self, + simulation_report: Dict[str, Any], + *, + window_size: int = 5, + ) -> Dict[str, Any]: + chapter_trace_map = { + str(item.get("chapter_id") or ""): dict(item) + for item in list(simulation_report.get("chapter_trace") or []) + if str(item.get("chapter_id") or "") + } + chapter_snapshots: List[Dict[str, Any]] = [] + for index, payload in enumerate(list(simulation_report.get("chapter_evaluations") or []), start=1): + chapter_id = str(payload.get("chapter_id") or f"chapter_{index}") + trace = chapter_trace_map.get(chapter_id, {}) + scores = dict(payload.get("scores") or {}) + lint_metrics = dict((payload.get("hard_validator_results") or {}).get("lint_metrics") or {}) + execution = dict(trace.get("chapter_task_execution_summary") or {}) + chapter_snapshots.append( + { + "chapter_index": int(execution.get("series_chapter_index", index) or index), + "scene_function": str(trace.get("scene_function") or ""), + "pacing": round(float(scores.get("pacing", 0.0) or 0.0), 3), + "hook_quality": round(float(scores.get("hook_quality", 0.0) or 0.0), 3), + "exposition_ratio": round(float(lint_metrics.get("exposition_ratio", 0.0) or 0.0), 3), + "issue_codes": [ + str(issue.get("issue_code") or "") + for issue in list(payload.get("issues") or []) + if str(issue.get("issue_code") or "") + ], + } + ) + if not chapter_snapshots: + return {"available": False} + tail = chapter_snapshots[-min(window_size, len(chapter_snapshots)) :] + repeated_transitions = 0 + transition_count = 0 + previous_scene_function: Optional[str] = None + for snapshot in tail: + scene_function = str(snapshot.get("scene_function") or "") + if previous_scene_function is not None and scene_function: + transition_count += 1 + if previous_scene_function == scene_function: + repeated_transitions += 1 + if scene_function: + previous_scene_function = scene_function + final_state = dict(simulation_report.get("final_state_snapshot") or {}) + open_promises = list(final_state.get("open_promises") or []) + return { + "available": True, + "window_size": len(tail), + "window_start_chapter": int(tail[0].get("chapter_index", 0) or 0), + "window_end_chapter": int(tail[-1].get("chapter_index", 0) or 0), + "avg_pacing": round(sum(float(item.get("pacing", 0.0) or 0.0) for item in tail) / float(max(1, len(tail))), 3), + "avg_hook_quality": round(sum(float(item.get("hook_quality", 0.0) or 0.0) for item in tail) / float(max(1, len(tail))), 3), + "avg_exposition_ratio": round(sum(float(item.get("exposition_ratio", 0.0) or 0.0) for item in tail) / float(max(1, len(tail))), 3), + "scene_family_repeat_ratio": round(repeated_transitions / float(max(1, transition_count)), 3), + "open_promises_at_end": len(open_promises), + "issue_codes": sorted( + { + str(issue_code) + for item in tail + for issue_code in list(item.get("issue_codes") or []) + if str(issue_code) + } + ), + } + + def _chapter_trace_with_promise_deltas(self, simulation_report: Dict[str, Any]) -> List[Dict[str, Any]]: + chapter_trace = [dict(item) for item in simulation_report.get("chapter_trace", [])] + seen_closed: set[str] = set() + for index, item in enumerate(chapter_trace, start=1): + execution = dict(item.get("chapter_task_execution_summary") or {}) + chapter_index = int(execution.get("series_chapter_index", index) or index) + cumulative_closed = [str(promise_id) for promise_id in item.get("closed_promise_ids", []) if str(promise_id)] + closed_delta = [promise_id for promise_id in cumulative_closed if promise_id not in seen_closed] + seen_closed.update(closed_delta) + item["simulation_chapter_index"] = chapter_index + item["open_promise_ids"] = [str(promise_id) for promise_id in item.get("open_promise_ids", []) if str(promise_id)] + item["closed_promise_ids_delta"] = closed_delta + item["anchor"] = { + "anchor_type": "simulation", + "anchor_key": str(chapter_index), + } + return chapter_trace + + def _promise_catalog_from_simulation( + self, simulation_report: Dict[str, Any], chapter_trace: List[Dict[str, Any]] + ) -> tuple[Dict[str, Dict[str, Any]], Dict[str, int], Dict[str, int]]: + final_state = dict(simulation_report.get("final_state_snapshot") or {}) + current_turn = int(final_state.get("turn_index", simulation_report.get("completed_chapters", 0)) or 0) + open_promises = [dict(item) for item in final_state.get("open_promises", [])] + promise_catalog: Dict[str, Dict[str, Any]] = {} + for promise in open_promises: + promise_id = str(promise.get("promise_id") or "") + if not promise_id: + continue + due_by_turn = int(promise.get("due_by_turn", current_turn) or current_turn) + promise_catalog[promise_id] = { + "promise_id": promise_id, + "description": promise.get("description", ""), + "holders": list(promise.get("holders", [])), + "stakes": promise.get("stakes"), + "status": promise.get("status"), + "opened_at_turn": promise.get("opened_at_turn"), + "due_by_turn": promise.get("due_by_turn"), + "is_overdue": due_by_turn <= current_turn, + "source": "open", + } + first_seen: Dict[str, int] = {} + last_seen: Dict[str, int] = {} + for item in chapter_trace: + chapter_index = int(item.get("simulation_chapter_index", 0) or 0) + touched_promise_ids = sorted( + { + *[str(promise_id) for promise_id in item.get("open_promise_ids", []) if str(promise_id)], + *[str(promise_id) for promise_id in item.get("closed_promise_ids_delta", []) if str(promise_id)], + } + ) + for promise_id in touched_promise_ids: + if promise_id not in first_seen: + first_seen[promise_id] = chapter_index + last_seen[promise_id] = chapter_index + promise_catalog.setdefault( + promise_id, + { + "promise_id": promise_id, + "description": "", + "holders": [], + "stakes": None, + "status": "closed", + "opened_at_turn": None, + "due_by_turn": None, + "is_overdue": False, + "source": "closed_only", + }, + ) + return promise_catalog, first_seen, last_seen + + def _materialize_promise_items( + self, + promise_ids: List[str], + promise_catalog: Dict[str, Dict[str, Any]], + first_seen: Dict[str, int], + last_seen: Dict[str, int], + *, + promise_state_overrides: Optional[Dict[str, Dict[str, Any]]] = None, + ) -> List[Dict[str, Any]]: + items = [] + overrides = promise_state_overrides or {} + for promise_id in promise_ids: + if not promise_id: + continue + payload = dict( + promise_catalog.get( + promise_id, + { + "promise_id": promise_id, + "description": "", + "holders": [], + "stakes": None, + "status": "unknown", + "opened_at_turn": None, + "due_by_turn": None, + "is_overdue": False, + "source": "unknown", + }, + ) + ) + payload["first_seen_chapter"] = first_seen.get(promise_id) + payload["last_seen_chapter"] = last_seen.get(promise_id) + payload["anchor"] = { + "anchor_type": "simulation", + "anchor_key": str(last_seen.get(promise_id) or first_seen.get(promise_id) or ""), + } + override = dict(overrides.get(promise_id) or {}) + payload["editor_state"] = override.get("editor_state", "") + payload["editor_notes"] = override.get("notes", "") + payload["editor_updated_at"] = override.get("updated_at") + payload["editor_context"] = { + "chapter_index": override.get("chapter_index"), + "chapter_task_id": override.get("chapter_task_id"), + "arc_id": override.get("arc_id"), + "volume_id": override.get("volume_id"), + } + payload["has_editor_override"] = bool(override) + items.append(payload) + return items + + def _build_promise_state_workbench(self, metadata: Dict[str, Any], simulation_report: Dict[str, Any]) -> Dict[str, Any]: + if not simulation_report: + return {} + chapter_trace = self._chapter_trace_with_promise_deltas(simulation_report) + promise_catalog, first_seen, last_seen = self._promise_catalog_from_simulation(simulation_report, chapter_trace) + overrides = self._promise_state_overrides(metadata) + editable_ids = sorted( + promise_catalog.keys(), + key=lambda promise_id: ( + 0 if promise_catalog.get(promise_id, {}).get("status") == "open" else 1, + first_seen.get(promise_id, 10**9), + promise_id, + ), + ) + editable_promises = self._materialize_promise_items( + editable_ids, + promise_catalog, + first_seen, + last_seen, + promise_state_overrides=overrides, + ) + return { + "available": True, + "state_options": list(self.promise_editor_states), + "override_count": sum(1 for item in editable_promises if item.get("has_editor_override")), + "editable_promises": editable_promises, + "next_actions": ( + ["review_escalated_promises", "resolve_or_defer_open_promises"] + if any(item.get("editor_state") == "escalate" for item in editable_promises) + else (["review_open_promises"] if editable_promises else ["run_longform_simulation"]) + ), + } + + def _build_series_volume_arc_promise_mapping(self, simulation_report: Dict[str, Any]) -> Dict[str, Any]: + if not simulation_report: + return {} + chapter_trace = self._chapter_trace_with_promise_deltas(simulation_report) + plan_snapshot = dict(simulation_report.get("longform_plan_snapshot") or {}) + series_plan = dict(plan_snapshot.get("series_plan") or {}) + volume_plans = [dict(item) for item in plan_snapshot.get("volume_plans", [])] + arc_plans = [dict(item) for item in plan_snapshot.get("arc_plans", [])] + promise_catalog, first_seen, last_seen = self._promise_catalog_from_simulation(simulation_report, chapter_trace) + promise_state_overrides = self._promise_state_overrides(dict(simulation_report.get("_draft_metadata", {}) or {})) + + def _scope_summary(trace_items: List[Dict[str, Any]]) -> Dict[str, Any]: + chapter_indexes = [int(item.get("simulation_chapter_index", 0) or 0) for item in trace_items] + open_ids = sorted( + { + str(promise_id) + for item in trace_items + for promise_id in item.get("open_promise_ids", []) + if str(promise_id) + } + ) + closed_ids = sorted( + { + str(promise_id) + for item in trace_items + for promise_id in item.get("closed_promise_ids_delta", []) + if str(promise_id) + } + ) + mapped_ids = sorted(set(open_ids) | set(closed_ids)) + return { + "simulated_chapter_count": len(trace_items), + "first_simulation_chapter": min(chapter_indexes) if chapter_indexes else None, + "last_simulation_chapter": max(chapter_indexes) if chapter_indexes else None, + "open_promise_ids": open_ids, + "closed_promise_ids": closed_ids, + "mapped_promises": self._materialize_promise_items( + mapped_ids, + promise_catalog, + first_seen, + last_seen, + promise_state_overrides=promise_state_overrides, + ), + } + + volume_maps = [] + for volume in volume_plans: + trace_items = [item for item in chapter_trace if item.get("volume_id") == volume.get("volume_id")] + scope = _scope_summary(trace_items) + volume_maps.append( + { + "volume_id": volume.get("volume_id"), + "order": volume.get("order"), + "title": volume.get("title"), + "goal": volume.get("goal"), + "target_chapters": volume.get("target_chapters"), + "climax_definition": volume.get("climax_definition"), + "end_state": volume.get("end_state"), + "arc_ids": [str(arc.get("arc_id")) for arc in arc_plans if arc.get("volume_id") == volume.get("volume_id")], + **scope, + "anchor": { + "anchor_type": "simulation", + "anchor_key": str(scope.get("first_simulation_chapter") or ""), + }, + } + ) + + arc_maps = [] + for arc in arc_plans: + trace_items = [item for item in chapter_trace if item.get("arc_id") == arc.get("arc_id")] + scope = _scope_summary(trace_items) + arc_maps.append( + { + "arc_id": arc.get("arc_id"), + "volume_id": arc.get("volume_id"), + "order": arc.get("order"), + "title": arc.get("title"), + "goal": arc.get("goal"), + "conflict": arc.get("conflict"), + "target_chapters": arc.get("target_chapters"), + "reveal_budget": arc.get("reveal_budget"), + "payoff_targets": list(arc.get("payoff_targets", [])), + "completion_conditions": list(arc.get("completion_conditions", [])), + "chapter_task_ids": [ + str(task.get("chapter_task_id")) + for task in arc.get("chapter_tasks", []) + if task.get("chapter_task_id") + ], + **scope, + "anchor": { + "anchor_type": "simulation", + "anchor_key": str(scope.get("first_simulation_chapter") or ""), + }, + } + ) + + series_scope = _scope_summary(chapter_trace) + return { + "available": True, + "series_summary": { + "series_id": series_plan.get("series_id"), + "title": series_plan.get("title"), + "theme_statement": series_plan.get("theme_statement"), + "target_chapters": series_plan.get("total_chapter_target"), + "target_word_count": series_plan.get("target_word_count"), + "volume_count": len(volume_plans), + "arc_count": len(arc_plans), + **series_scope, }, - "simulation_drilldown": self._build_simulation_drilldown(dict(version.simulation_report_json or {})), - "revision_compare": self._build_revision_compare(metadata, dict(version.simulation_report_json or {})), - "before_after_chapter_compare": self._build_before_after_chapter_compare(metadata), + "volumes": volume_maps, + "arcs": arc_maps, + "next_actions": ( + ["review_arc_promise_map", "review_task_simulation_links"] + if chapter_trace + else ["run_longform_simulation"] + ), + } + + def _build_chapter_task_simulation_linking(self, simulation_report: Dict[str, Any]) -> Dict[str, Any]: + if not simulation_report: + return {} + chapter_trace = self._chapter_trace_with_promise_deltas(simulation_report) + chapter_compare = self._build_before_after_chapter_compare(dict(simulation_report.get("_draft_metadata", {}) or {})) + chapter_compare_map = { + int(chapter_index): dict(payload) + for chapter_index, payload in dict(chapter_compare.get("chapter_compare_map", {}) or {}).items() } + plan_snapshot = dict(simulation_report.get("longform_plan_snapshot") or {}) + volume_plans = {str(item.get("volume_id")): dict(item) for item in plan_snapshot.get("volume_plans", [])} + arc_plans = [dict(item) for item in plan_snapshot.get("arc_plans", [])] + promise_catalog, first_seen, last_seen = self._promise_catalog_from_simulation(simulation_report, chapter_trace) + promise_state_overrides = self._promise_state_overrides(dict(simulation_report.get("_draft_metadata", {}) or {})) + + task_links = [] + for arc in arc_plans: + volume = volume_plans.get(str(arc.get("volume_id") or ""), {}) + for task_order, task in enumerate(arc.get("chapter_tasks", []), start=1): + task_id = str(task.get("chapter_task_id") or "") + trace_items = [ + item + for item in chapter_trace + if str((item.get("chapter_task") or {}).get("chapter_task_id") or "") == task_id + ] + linked_open_ids = sorted( + { + str(promise_id) + for item in trace_items + for promise_id in item.get("open_promise_ids", []) + if str(promise_id) + } + ) + linked_closed_ids = sorted( + { + str(promise_id) + for item in trace_items + for promise_id in item.get("closed_promise_ids_delta", []) + if str(promise_id) + } + ) + linked_chapters = [ + { + "chapter_index": int(item.get("simulation_chapter_index", 0) or 0), + "chapter_id": item.get("chapter_id"), + "chapter_title": item.get("chapter_title"), + "scene_function": item.get("scene_function"), + "decision": dict(item.get("evaluation") or {}).get("decision"), + "overall_score": float(dict(item.get("evaluation") or {}).get("overall_score", 0.0)), + "issue_codes": list(dict(item.get("evaluation") or {}).get("issue_codes", [])), + "open_promise_ids": list(item.get("open_promise_ids", [])), + "closed_promise_ids": list(item.get("closed_promise_ids_delta", [])), + "anchor": dict(item.get("anchor") or {}), + } + for item in trace_items + ] + compare_chapters = [ + dict(chapter_compare_map.get(int(item.get("chapter_index", 0) or 0)) or {}) + for item in linked_chapters + if chapter_compare_map.get(int(item.get("chapter_index", 0) or 0)) + ] + issue_codes_added = sorted( + { + str(issue_code) + for item in compare_chapters + for issue_code in item.get("issue_codes_added", []) + if str(issue_code) + } + ) + issue_codes_removed = sorted( + { + str(issue_code) + for item in compare_chapters + for issue_code in item.get("issue_codes_removed", []) + if str(issue_code) + } + ) + average_score_delta = round( + sum(float(item.get("overall_score_delta", 0.0)) for item in compare_chapters) / float(max(1, len(compare_chapters))), + 3, + ) if compare_chapters else 0.0 + strongest_compare = sorted( + compare_chapters, + key=lambda item: ( + -abs(float(item.get("overall_score_delta", 0.0))), + int(item.get("chapter_index", 0) or 0), + ), + )[0] if compare_chapters else {} + planned_ids = [str(item) for item in task.get("promise_targets", []) if str(item)] + observed_ids = [str(item.get("promise_id")) for item in self._materialize_promise_items( + sorted(set(linked_open_ids) | set(linked_closed_ids)), + promise_catalog, + first_seen, + last_seen, + promise_state_overrides=promise_state_overrides, + ) if str(item.get("promise_id"))] + matched_ids = sorted(set(planned_ids) & set(observed_ids)) + planned_only_ids = sorted(set(planned_ids) - set(observed_ids)) + observed_only_ids = sorted(set(observed_ids) - set(planned_ids)) + if not planned_ids and not observed_ids: + drift_status = "no_signal" + elif planned_only_ids and observed_only_ids: + drift_status = "diverged" + elif planned_only_ids: + drift_status = "planned_only" + elif observed_only_ids: + drift_status = "observed_only" + else: + drift_status = "aligned" + coverage_ratio = ( + round(len(matched_ids) / float(max(1, len(planned_ids))), 3) + if planned_ids + else (1.0 if not observed_only_ids else 0.0) + ) + planned_promises = self._materialize_promise_items( + planned_ids, + promise_catalog, + first_seen, + last_seen, + promise_state_overrides=promise_state_overrides, + ) + observed_promises = self._materialize_promise_items( + sorted(set(linked_open_ids) | set(linked_closed_ids)), + promise_catalog, + first_seen, + last_seen, + promise_state_overrides=promise_state_overrides, + ) + remediation_suggestions: List[Dict[str, Any]] = [] + if planned_only_ids: + remediation_suggestions.append( + { + "action": "split_targets_or_expand_scene", + "summary": "计划中的 promise targets 还没在 simulation 中命中。", + "details": f"planned only: {', '.join(planned_only_ids)}", + } + ) + if observed_only_ids: + remediation_suggestions.append( + { + "action": "merge_observed_promises", + "summary": "simulation 出现了 task 计划外的 promise。", + "details": f"observed only: {', '.join(observed_only_ids)}", + } + ) + if issue_codes_added: + remediation_suggestions.append( + { + "action": "rewrite_from_compare", + "summary": "章节对照里出现新增 issue,建议从 compare 回写 task。", + "details": f"issues added: {', '.join(issue_codes_added)}", + } + ) + first_linked_chapter_index = ( + int(dict(linked_chapters[0]).get("chapter_index", 0) or 0) + if linked_chapters + else 0 + ) + rewrite_target_chapter_index = ( + int(strongest_compare.get("chapter_index", 0) or 0) + or first_linked_chapter_index + ) + suggested_override_state = ( + "needs_rewrite" + if issue_codes_added or drift_status in {"diverged", "planned_only"} + else ("accepted_tradeoff" if drift_status == "observed_only" else "watch") + ) + rewrite_focus = [] + if planned_only_ids: + rewrite_focus.append(f"补上未命中的 promise:{' / '.join(planned_only_ids)}") + if observed_only_ids: + rewrite_focus.append(f"决定是否并入新出现的 promise:{' / '.join(observed_only_ids)}") + if issue_codes_added: + rewrite_focus.append(f"处理新增 issue:{' / '.join(issue_codes_added)}") + if not rewrite_focus: + rewrite_focus.append("保持当前任务目标与章节对照的一致性。") + suggested_objective = ( + f"{task.get('objective') or '推进当前任务。'} " + f"{';'.join(rewrite_focus)}" + ).strip() + task_links.append( + { + "chapter_task_id": task_id, + "task_order": task_order, + "status": "linked" if linked_chapters else "planned_only", + "volume_id": arc.get("volume_id"), + "volume_title": volume.get("title"), + "arc_id": arc.get("arc_id"), + "arc_title": arc.get("title"), + "duty_type": task.get("duty_type"), + "objective": task.get("objective"), + "target_words": task.get("target_words"), + "reveal_budget": task.get("reveal_budget"), + "promise_actions": list(task.get("promise_actions", [])), + "promise_targets": list(task.get("promise_targets", [])), + "planned_promises": planned_promises, + "allow_terminal": bool(task.get("allow_terminal")), + "simulated_chapter_count": len(linked_chapters), + "linked_chapters": linked_chapters, + "compare_available": bool(compare_chapters), + "compare_chapters": compare_chapters, + "compare_summary": { + "compared_chapter_count": len(compare_chapters), + "average_score_delta": average_score_delta, + "issue_codes_added": issue_codes_added, + "issue_codes_removed": issue_codes_removed, + "strongest_compare_chapter_index": strongest_compare.get("chapter_index"), + "strongest_compare_delta": float(strongest_compare.get("overall_score_delta", 0.0)), + }, + "linked_open_promise_ids": linked_open_ids, + "linked_closed_promise_ids": linked_closed_ids, + "mapped_promises": observed_promises, + "promise_drift": { + "status": drift_status, + "coverage_ratio": coverage_ratio, + "planned_target_count": len(planned_ids), + "observed_target_count": len(observed_ids), + "matched_target_count": len(matched_ids), + "planned_target_ids": planned_ids, + "observed_target_ids": observed_ids, + "matched_target_ids": matched_ids, + "planned_only_ids": planned_only_ids, + "observed_only_ids": observed_only_ids, + "recommended_actions": ( + ["split_or_trim_planned_targets", "merge_observed_promises"] + if drift_status == "diverged" + else (["merge_observed_promises"] if drift_status == "observed_only" else (["review_unhit_targets"] if drift_status == "planned_only" else ["continue"])) + ), + }, + "remediation_suggestions": remediation_suggestions, + "rewrite_workflow": { + "available": bool(linked_chapters or compare_chapters or remediation_suggestions), + "rewrite_target_chapter_index": rewrite_target_chapter_index or None, + "suggested_override_state": suggested_override_state, + "issue_scope": issue_codes_added, + "suggested_task_objective": suggested_objective, + "suggested_bulk_notes": "先查看章节对照,再更新当前 task 的 objective / promise targets,并重新运行 simulation。", + "suggested_promise_targets": sorted(set(planned_ids) | set(observed_ids)), + "next_actions": ["jump_to_compare", "apply_rewrite_prefill", "save_longform_workbench", "re_simulate"], + }, + "next_actions": ( + ["review_task_compare_diff", "review_promise_drift", "review_linked_chapters", "review_linked_promises", "apply_rewrite_prefill"] + if linked_chapters + else ["run_longform_simulation_for_task"] + ), + } + ) + + return { + "available": True, + "task_links": task_links, + "linked_task_count": sum(1 for item in task_links if item.get("status") == "linked"), + "planned_only_task_count": sum(1 for item in task_links if item.get("status") != "linked"), + "next_actions": ( + ["review_task_simulation_links"] + if any(item.get("status") == "linked" for item in task_links) + else ["run_longform_simulation"] + ), + } + + def _apply_continuity_override(self, payload: Dict[str, Any], overrides: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: + chapter_key = str(int(payload.get("chapter_index", 0) or 0)) + override = dict(overrides.get(chapter_key) or {}) + merged = dict(payload) + merged["override_state"] = override.get("override_state", "") + merged["override_notes"] = override.get("notes", "") + merged["override_issue_scope"] = list(override.get("issue_scope", []) or []) + merged["override_updated_at"] = override.get("updated_at") + merged["has_override"] = bool(override) + return merged + + def _build_continuity_override_workbench(self, metadata: Dict[str, Any], simulation_report: Dict[str, Any]) -> Dict[str, Any]: + if not simulation_report: + return {} + continuity = self._build_continuity_diff_workbench(metadata, simulation_report) + if not continuity.get("available"): + return {} + chapter_compare = self._build_before_after_chapter_compare(metadata) + overrides = self._continuity_overrides(metadata) + chapter_trace = self._chapter_trace_with_promise_deltas(simulation_report) + candidates: List[Dict[str, Any]] = [] + seen: set[int] = set() + + def _push(items: List[Dict[str, Any]], source: str) -> None: + for item in items: + chapter_index = int(item.get("chapter_index", 0) or 0) + if not chapter_index or chapter_index in seen: + continue + seen.add(chapter_index) + merged = self._apply_continuity_override(item, overrides) + merged["source"] = source + candidates.append(merged) + + _push(list(chapter_compare.get("top_changed_chapters", [])), "compare") + _push(list(continuity.get("drifting_characters", [])), "drift") + _push(list(continuity.get("causal_breaks", [])), "causal") + _push(list(continuity.get("promise_risks", [])), "promise") + for item in chapter_trace[:8]: + chapter_index = int(item.get("simulation_chapter_index", 0) or 0) + if not chapter_index or chapter_index in seen: + continue + seen.add(chapter_index) + merged = self._apply_continuity_override( + { + "chapter_index": chapter_index, + "chapter_title": item.get("chapter_title"), + "scene_function": item.get("scene_function"), + "issue_codes": list(dict(item.get("evaluation") or {}).get("issue_codes", [])), + "chapter_task_id": str((item.get("chapter_task") or {}).get("chapter_task_id") or ""), + "arc_id": item.get("arc_id"), + "volume_id": item.get("volume_id"), + }, + overrides, + ) + merged["source"] = "trace" + candidates.append(merged) + for chapter_key in sorted(overrides.keys(), key=lambda value: int(value)): + chapter_index = int(chapter_key) + if chapter_index in seen: + continue + seen.add(chapter_index) + merged = self._apply_continuity_override({"chapter_index": chapter_index}, overrides) + merged["source"] = "override_only" + candidates.append(merged) + + return { + "available": True, + "state_options": list(self.continuity_override_states), + "override_count": sum(1 for item in candidates if item.get("has_override")), + "candidate_chapters": candidates, + "next_actions": ( + ["review_escalated_continuity", "jump_to_compare_chapter"] + if any(item.get("override_state") == "escalate" for item in candidates) + else (["review_compare_chapters"] if candidates else ["run_longform_simulation"]) + ), + } + + def _build_continuity_diff_workbench(self, metadata: Dict[str, Any], simulation_report: Dict[str, Any]) -> Dict[str, Any]: + if not simulation_report: + return {} + chapter_compare = self._build_before_after_chapter_compare(metadata) + continuity_overrides = self._continuity_overrides(metadata) + chapter_trace = { + item.get("chapter_id"): dict(item) + for item in simulation_report.get("chapter_trace", []) + if item.get("chapter_id") + } + chapter_evaluations = list(simulation_report.get("chapter_evaluations", [])) + drifting_characters = [] + causal_breaks = [] + promise_risks = [] + for index, payload in enumerate(chapter_evaluations, start=1): + issues = list(payload.get("issues", [])) + chapter_id = str(payload.get("chapter_id") or f"chapter_{index}") + trace = chapter_trace.get(chapter_id, {}) + issue_codes = [str(item.get("issue_code") or "") for item in issues if item.get("issue_code")] + if "Q06" in issue_codes: + drifting_characters.append( + self._apply_continuity_override( + { + "chapter_index": index, + "chapter_title": trace.get("chapter_title") or chapter_id, + "scene_function": trace.get("scene_function"), + "issue_codes": issue_codes, + }, + continuity_overrides, + ) + ) + if "Q07" in issue_codes: + causal_breaks.append( + self._apply_continuity_override( + { + "chapter_index": index, + "chapter_title": trace.get("chapter_title") or chapter_id, + "scene_function": trace.get("scene_function"), + "issue_codes": issue_codes, + }, + continuity_overrides, + ) + ) + if "Q09" in issue_codes: + promise_risks.append( + self._apply_continuity_override( + { + "chapter_index": index, + "chapter_title": trace.get("chapter_title") or chapter_id, + "issue_codes": issue_codes, + }, + continuity_overrides, + ) + ) + top_changed_chapters = [ + self._apply_continuity_override(item, continuity_overrides) + for item in list(chapter_compare.get("top_changed_chapters", [])) + ] + return { + "available": True, + "simulation_freshness": self._simulation_freshness(metadata, simulation_report), + "drifting_characters": drifting_characters[:6], + "causal_breaks": causal_breaks[:6], + "promise_risks": promise_risks[:6], + "top_changed_chapters": top_changed_chapters[:6], + "revision_compare": self._build_revision_compare(metadata, simulation_report), + "next_actions": ( + ["re_simulate_for_continuity"] + if drifting_characters or causal_breaks or promise_risks + else (["review_before_after_compare"] if top_changed_chapters else ["continuity_stable"]) + ), + } + + def _build_character_fidelity_remediation_framework(self, simulation_report: Dict[str, Any]) -> Dict[str, Any]: + if not simulation_report: + return {} + chapter_trace = { + str(item.get("chapter_id") or ""): dict(item) + for item in simulation_report.get("chapter_trace", []) + if str(item.get("chapter_id") or "") + } + chapter_evaluations = list(simulation_report.get("chapter_evaluations", [])) + q06_chapters: List[Dict[str, Any]] = [] + character_hotspots: Dict[str, Dict[str, Any]] = {} + duty_hotspots: Dict[str, Dict[str, Any]] = {} + low_fidelity_count = 0 + for index, payload in enumerate(chapter_evaluations, start=1): + chapter_id = str(payload.get("chapter_id") or f"chapter_{index}") + trace = chapter_trace.get(chapter_id, {}) + score_block = dict(payload.get("scores") or {}) + issue_codes = [str(item.get("issue_code") or "") for item in payload.get("issues", []) if str(item.get("issue_code") or "")] + fidelity_score = float(score_block.get("character_fidelity", 0.0) or 0.0) + if fidelity_score < 0.34: + low_fidelity_count += 1 + if "Q06" not in issue_codes and fidelity_score >= 0.34: + continue + chapter_index = int( + dict(trace.get("chapter_task_execution_summary") or {}).get("series_chapter_index") + or str(chapter_id).rsplit("_", 1)[-1] + or index + ) + actor_ids = [str(item) for item in ((trace.get("chosen_event") or {}).get("actors", []) or trace.get("actor_ids", []) or []) if str(item)] + chapter_task = dict(trace.get("chapter_task") or {}) + duty_type = str(chapter_task.get("duty_type") or "unknown") + q06_chapters.append( + { + "chapter_index": chapter_index, + "chapter_id": chapter_id, + "chapter_title": trace.get("chapter_title") or chapter_id, + "scene_function": trace.get("scene_function"), + "duty_type": duty_type, + "actor_ids": actor_ids, + "character_fidelity": round(fidelity_score, 3), + "issue_codes": issue_codes, + "chapter_task_id": chapter_task.get("chapter_task_id"), + } + ) + duty_entry = duty_hotspots.setdefault( + duty_type, + {"duty_type": duty_type, "count": 0, "lowest_fidelity": 1.0}, + ) + duty_entry["count"] += 1 + duty_entry["lowest_fidelity"] = min(float(duty_entry["lowest_fidelity"]), fidelity_score) + for actor_id in actor_ids: + character_entry = character_hotspots.setdefault( + actor_id, + {"character_id": actor_id, "count": 0, "lowest_fidelity": 1.0}, + ) + character_entry["count"] += 1 + character_entry["lowest_fidelity"] = min(float(character_entry["lowest_fidelity"]), fidelity_score) + ranked_characters = sorted( + character_hotspots.values(), + key=lambda item: (-int(item["count"]), float(item["lowest_fidelity"]), str(item["character_id"])), + ) + ranked_duties = sorted( + duty_hotspots.values(), + key=lambda item: (-int(item["count"]), float(item["lowest_fidelity"]), str(item["duty_type"])), + ) + q06_share = round(len(q06_chapters) / float(max(1, len(chapter_evaluations))), 3) if chapter_evaluations else 0.0 + return { + "available": True, + "status": "active" if q06_chapters else "clear", + "q06_chapter_count": len(q06_chapters), + "q06_chapter_share": q06_share, + "low_fidelity_chapter_count": low_fidelity_count, + "top_character_hotspots": [ + { + **item, + "lowest_fidelity": round(float(item["lowest_fidelity"]), 3), + } + for item in ranked_characters[:6] + ], + "top_duty_hotspots": [ + { + **item, + "lowest_fidelity": round(float(item["lowest_fidelity"]), 3), + } + for item in ranked_duties[:6] + ], + "priority_chapters": sorted(q06_chapters, key=lambda item: (float(item["character_fidelity"]), int(item["chapter_index"])))[:8], + "suggested_asset_focus": [ + {"asset": "characters", "reason": "tighten vow/wound/destiny alignment for hotspot actors"}, + {"asset": "emotion_action_policies", "reason": "align reaction defaults with current pressure and duty"}, + {"asset": "scene_blueprints", "reason": "reduce duty-level drift in Q06-heavy task patterns"}, + ], + "next_actions": ( + ["inspect_q06_priority_chapters", "tighten_character_cards", "tighten_emotion_action_policies"] + if q06_chapters + else ["character_fidelity_stable"] + ), + } + + def _build_steering_checkpoint_summary(self, simulation_report: Dict[str, Any]) -> Dict[str, Any]: + checkpoints = [dict(item) for item in simulation_report.get("steering_checkpoints", [])] + if not checkpoints: + return {} + return { + "available": True, + "count": len(checkpoints), + "latest": checkpoints[-1], + "scenario_kinds": sorted({str(item.get("scenario_kind") or "") for item in checkpoints if str(item.get("scenario_kind") or "")}), + } + + def _build_replan_history_summary(self, simulation_report: Dict[str, Any]) -> Dict[str, Any]: + history = [dict(item) for item in simulation_report.get("replan_history", [])] + if not history: + return {} + return { + "available": True, + "count": len(history), + "latest": history[-1], + "entries": history[-10:], + } + + def _build_memory_patch_summary_view(self, simulation_report: Dict[str, Any]) -> Dict[str, Any]: + summary = dict(simulation_report.get("memory_patch_summary") or {}) + return {"available": bool(summary), **summary} if summary else {} + + def _character_label_lookup(self, worldpack_payload: Dict[str, Any], final_state: Dict[str, Any]) -> Dict[str, Dict[str, str]]: + lookup: Dict[str, Dict[str, str]] = {} + for character in worldpack_payload.get("characters", []) or []: + payload = dict(character or {}) + character_id = str(payload.get("character_id") or "").strip() + if not character_id: + continue + lookup[character_id] = { + "label": str(payload.get("display_name") or payload.get("name") or character_id), + "role": str(payload.get("role") or ""), + } + for character_id, payload in dict(final_state.get("characters") or {}).items(): + state_payload = dict(payload or {}) + lookup[str(character_id)] = { + "label": str( + state_payload.get("name") + or state_payload.get("display_name") + or lookup.get(str(character_id), {}).get("label") + or character_id + ), + "role": str(state_payload.get("role") or lookup.get(str(character_id), {}).get("role") or ""), + } + return lookup + + def _issue_asset_priority_templates(self, issue_code: str) -> List[Dict[str, str]]: + templates: Dict[str, List[Dict[str, str]]] = { + "Q03": [ + {"asset_type": "scene_blueprint", "label": "场景蓝图", "reason": "先改 beats 和 required roles,把重复段落模板拆开。"}, + {"asset_type": "chapter_task", "label": "章节任务", "reason": "如果重复来自 duty/objective 雷同,再拆目标和 reveal budget。"}, + {"asset_type": "character_card", "label": "角色卡", "reason": "如果说话和反应还像同一个人,再补角色差异。"}, + ], + "Q04": [ + {"asset_type": "scene_blueprint", "label": "场景蓝图", "reason": "先把解释改成对白、动作和环境触发的 scene beats。"}, + {"asset_type": "character_card", "label": "角色卡", "reason": "再收紧人物的 public self / shadow desire,让台词更含蓄。"}, + {"asset_type": "chapter_task", "label": "章节任务", "reason": "如果这一章承载过多说明,再收窄 objective。"}, + ], + "Q05": [ + {"asset_type": "scene_blueprint", "label": "场景蓝图", "reason": "先补物件、动作、声响和空间触感。"}, + {"asset_type": "character_card", "label": "角色卡", "reason": "再补人物的动作习惯和身体反应,让细节落到人身上。"}, + {"asset_type": "chapter_task", "label": "章节任务", "reason": "如果细节不足来自章节负担过重,再调字数和 reveal budget。"}, + ], + "Q09": [ + {"asset_type": "chapter_task", "label": "章节任务", "reason": "先改 objective / reveal budget / allow_terminal,修正章节收束速度。"}, + {"asset_type": "scene_blueprint", "label": "场景蓝图", "reason": "再补 hook、counter-reaction 和 payoff beat。"}, + {"asset_type": "character_card", "label": "角色卡", "reason": "如果角色愿望导致过早收束,再检查 vow / wound / destiny。"}, + ], + } + return [dict(item) for item in templates.get(issue_code, [])] + + def _asset_validation_panel(self, asset_type: str) -> Dict[str, str]: + mapping = { + "scene_blueprint": { + "validation_panel": "compare", + "validation_panel_label": "Compare", + "validation_reason": "改完 scene beats 后回 Compare,看章节前后差异是否真的消掉了问题。", + }, + "chapter_task": { + "validation_panel": "task_linking", + "validation_panel_label": "Task Linking", + "validation_reason": "改完任务后回 Task Linking,看章节覆盖、promise drift 和 compare summary 是否回正。", + }, + "character_card": { + "validation_panel": "continuity", + "validation_panel_label": "Continuity Diff", + "validation_reason": "改完角色卡后回 Continuity,看角色/因果/承诺漂移是否稳定。", + }, + } + return dict(mapping.get(asset_type, {})) + + def _build_issue_priority_groups(self, chapter_heatmap: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + severity_order = {"critical": 0, "watch": 1, "stable": 2} + groups: List[Dict[str, Any]] = [] + for issue_code in ("Q03", "Q04", "Q05", "Q09"): + impacted = [ + dict(item) + for item in chapter_heatmap + if issue_code in list(item.get("issue_codes") or []) + ] + if not impacted: + continue + impacted = sorted( + impacted, + key=lambda item: ( + severity_order.get(str(item.get("severity") or "stable"), 3), + float(item.get("overall_score", 0.0) or 0.0), + -int(item.get("issue_count", 0) or 0), + ), + ) + lead = impacted[0] + recommendations: List[Dict[str, Any]] = [] + for priority, template in enumerate(self._issue_asset_priority_templates(issue_code), start=1): + asset_type = template["asset_type"] + recommendation: Dict[str, Any] = { + "priority": priority, + "asset_type": asset_type, + "label": template["label"], + "reason": template["reason"], + "available": False, + **self._asset_validation_panel(asset_type), + "chapter_index": lead.get("chapter_index"), + "chapter_title": lead.get("chapter_title", ""), + } + if asset_type == "scene_blueprint" and (lead.get("scene_id") or lead.get("scene_function")): + recommendation.update( + { + "available": True, + "scene_id": lead.get("scene_id", ""), + "scene_function": lead.get("scene_function", ""), + "target_label": lead.get("scene_id") or lead.get("scene_function") or "scene", + } + ) + elif asset_type == "chapter_task" and (lead.get("chapter_task_id") or lead.get("arc_id") or lead.get("volume_id")): + recommendation.update( + { + "available": True, + "chapter_task_id": lead.get("chapter_task_id", ""), + "arc_id": lead.get("arc_id", ""), + "volume_id": lead.get("volume_id", ""), + "target_label": lead.get("chapter_task_id") or lead.get("arc_id") or lead.get("volume_id") or "task", + } + ) + elif asset_type == "character_card" and list(lead.get("related_character_ids") or []): + recommendation.update( + { + "available": True, + "character_id": lead["related_character_ids"][0], + "character_ids": list(lead.get("related_character_ids") or []), + "target_label": (lead.get("related_characters") or [lead["related_character_ids"][0]])[0], + } + ) + recommendations.append(recommendation) + primary = next((item for item in recommendations if item.get("available")), recommendations[0] if recommendations else {}) + groups.append( + { + "issue_code": issue_code, + "label": ISSUE_TAXONOMY.get(issue_code, {}).get("label", issue_code), + "chapter_count": len(impacted), + "fix_hint": ISSUE_TAXONOMY.get(issue_code, {}).get("fix_hint", ""), + "primary_asset_type": primary.get("asset_type", ""), + "primary_asset": dict(primary), + "primary_validation_panel": primary.get("validation_panel", ""), + "primary_validation_panel_label": primary.get("validation_panel_label", ""), + "asset_priorities": recommendations, + "chapters": [ + { + "chapter_index": item.get("chapter_index"), + "chapter_title": item.get("chapter_title"), + "scene_function": item.get("scene_function"), + "scene_id": item.get("scene_id"), + "chapter_task_id": item.get("chapter_task_id"), + "arc_id": item.get("arc_id"), + "volume_id": item.get("volume_id"), + "related_character_ids": list(item.get("related_character_ids") or []), + "related_characters": list(item.get("related_characters") or []), + } + for item in impacted[:3] + ], + } + ) + return groups + + def _decision_severity(self, decision: str) -> int: + return { + "pass": 0, + "rewrite": 1, + "block": 2, + }.get(str(decision or "pass"), 0) + + def _resolve_related_character_ids( + self, + *, + matched_scene: Dict[str, Any], + role_to_character_ids: Dict[str, List[str]], + character_lookup: Dict[str, Dict[str, str]], + ) -> List[str]: + related: List[str] = [] + for role_or_id in list((matched_scene or {}).get("required_roles") or []): + key = str(role_or_id or "").strip() + if not key: + continue + if key in role_to_character_ids: + related.extend([str(item) for item in role_to_character_ids.get(key, []) if str(item)]) + continue + if key in character_lookup: + related.append(key) + return list(dict.fromkeys(related)) + + def _content_quality_window_metrics(self, worldpack_payload: Dict[str, Any], simulation_report: Dict[str, Any]) -> Dict[str, Any]: + existing = dict(simulation_report.get("content_quality_contract_window_metrics") or {}) + if existing: + return existing + target_chapters = int( + ((simulation_report.get("longform_plan_snapshot") or {}).get("series_plan") or {}).get("total_chapter_target") + or ((worldpack_payload.get("series_plan") or {}).get("total_chapter_target") or 0) + or simulation_report.get("chapter_budget") + or 0 + ) + return content_quality_window_metrics( + chapter_report_payloads=list(simulation_report.get("chapter_evaluations") or []), + world_metrics={"target_chapters": target_chapters}, + ) + + def _content_quality_window_range(self, *, target_chapters: int, window_label: str) -> Dict[str, int]: + contract = resolve_content_quality_contract(target_chapters=target_chapters) + window = dict((contract.get("windows") or {}).get(window_label) or {}) + return { + "start": int(window.get("start", 0) or 0), + "end": int(window.get("end", 0) or 0), + } + + def _contract_issue_codes_from_failed_checks(self, failed_checks: List[str]) -> List[str]: + mapping = { + "repetition_score_cap": "Q03", + "rolling_window_repeat_breach": "Q03", + "exposition_ratio_cap": "Q04", + "dialogue_action_floor": "Q04", + "detail_density_floor": "Q05", + "mid_window_detail_breach": "Q05", + "late_window_detail_breach": "Q05", + "continuation_pressure_floor": "Q09", + "premature_terminal_forbidden": "Q09", + "late_window_q09_breach": "Q09", + "q09_pre_end": "Q09", + } + ordered = [] + for issue_code in ("Q09", "Q05", "Q04", "Q03"): + if any(mapping.get(str(name)) == issue_code for name in failed_checks): + ordered.append(issue_code) + return ordered + + def _content_quality_primary_asset_target( + self, + *, + issue_code: str, + targeted_chapters: List[Dict[str, Any]], + ) -> Dict[str, Any]: + base = issue_asset_target(issue_code) + if issue_code in {"Q03", "Q04"}: + ranked = sorted( + [dict(item) for item in targeted_chapters if str(item.get("scene_id") or "") or str(item.get("scene_function") or "")], + key=lambda item: ( + 0 if str(item.get("scene_id") or "") else 1, + -int(item.get("issue_count", 0) or 0), + float(item.get("overall_score", 0.0) or 0.0), + ), + ) + primary = ranked[0] if ranked else {} + return { + **base, + "target_label": str(primary.get("scene_id") or primary.get("scene_function") or base.get("asset_label") or ""), + "scene_id": str(primary.get("scene_id") or ""), + "scene_function": str(primary.get("scene_function") or ""), + } + ranked_tasks = sorted( + [dict(item) for item in targeted_chapters if str(item.get("chapter_task_id") or "") or str(item.get("arc_id") or "")], + key=lambda item: ( + 0 if str(item.get("chapter_task_id") or "") else 1, + -int(item.get("issue_count", 0) or 0), + float(item.get("overall_score", 0.0) or 0.0), + ), + ) + primary = ranked_tasks[0] if ranked_tasks else {} + return { + **base, + "target_label": str(primary.get("chapter_task_id") or primary.get("arc_id") or base.get("asset_label") or ""), + "chapter_task_id": str(primary.get("chapter_task_id") or ""), + "arc_id": str(primary.get("arc_id") or ""), + "volume_id": str(primary.get("volume_id") or ""), + } + + def _scene_required_character_ids( + self, + *, + worldpack_payload: Dict[str, Any], + scene_id: str, + ) -> List[str]: + scene = next( + ( + dict(item or {}) + for item in list(worldpack_payload.get("scene_blueprints") or []) + if str((dict(item or {})).get("scene_id") or "") == str(scene_id or "") + ), + {}, + ) + character_ids = { + str((dict(item or {})).get("character_id") or "") + for item in list(worldpack_payload.get("characters") or []) + if str((dict(item or {})).get("character_id") or "") + } + resolved: List[str] = [] + for role_or_id in list(scene.get("required_roles") or []): + candidate = str(role_or_id or "") + if candidate in character_ids and candidate not in resolved: + resolved.append(candidate) + return resolved + + def _content_quality_secondary_asset_targets( + self, + *, + worldpack_payload: Dict[str, Any], + issue_code: str, + targeted_chapters: List[Dict[str, Any]], + primary_asset_target: Dict[str, Any], + ) -> List[Dict[str, Any]]: + primary_scene_id = str(primary_asset_target.get("scene_id") or "") + primary_scene_function = str(primary_asset_target.get("scene_function") or "") + if issue_code in {"Q03", "Q04"} and primary_scene_function: + return [ + { + "asset_type": "scene_realization_contracts", + "asset_label": "场景实现合同", + "validation_panel": "compare", + "validation_panel_label": "Compare", + "target_label": f'default::{primary_scene_function}', + "contract_id": "default", + "scene_function": primary_scene_function, + }, + { + "asset_type": "emotion_action_policies", + "asset_label": "情绪动作策略", + "validation_panel": "compare", + "validation_panel_label": "Compare", + "target_label": f'default::{primary_scene_function}', + "policy_id": "default", + "scene_function": primary_scene_function, + }, + ] + + character_ids: List[str] = [] + if primary_scene_id: + character_ids.extend(self._scene_required_character_ids(worldpack_payload=worldpack_payload, scene_id=primary_scene_id)) + for chapter in targeted_chapters: + for item in chapter.get("related_character_ids", []) or []: + candidate = str(item or "") + if candidate and candidate not in character_ids: + character_ids.append(candidate) + if issue_code == "Q03" and character_ids: + character_id = character_ids[0] + return [ + { + "asset_type": "voice_profiles", + "asset_label": "角色声音配置", + "validation_panel": "compare", + "validation_panel_label": "Compare", + "target_label": character_id, + "character_id": character_id, + }, + { + "asset_type": "response_cadence_profiles", + "asset_label": "对白节奏配置", + "validation_panel": "compare", + "validation_panel_label": "Compare", + "target_label": character_id, + "character_id": character_id, + }, + ] + if issue_code == "Q04" and character_ids: + character_id = character_ids[0] + return [ + { + "asset_type": "character_card", + "asset_label": "角色卡", + "validation_panel": "continuity", + "validation_panel_label": "Continuity Diff", + "target_label": character_id, + "character_id": character_id, + }, + { + "asset_type": "response_cadence_profiles", + "asset_label": "对白节奏配置", + "validation_panel": "compare", + "validation_panel_label": "Compare", + "target_label": character_id, + "character_id": character_id, + }, + ] + if issue_code == "Q03": + return [] + if issue_code == "Q04": + return [] + if issue_code == "Q09": + for chapter in targeted_chapters: + arc_id = str(chapter.get("arc_id") or "") + if arc_id: + return [ + { + "asset_type": "arc_plan", + "asset_label": "弧线计划", + "validation_panel": "task_linking", + "validation_panel_label": "Task Linking", + "target_label": arc_id, + "arc_id": arc_id, + "volume_id": str(chapter.get("volume_id") or ""), + } + ] + return [] + + def _find_arc_payload(self, worldpack_payload: Dict[str, Any], arc_id: str) -> Dict[str, Any]: + for arc in list(worldpack_payload.get("arc_plans") or []): + if str((dict(arc or {})).get("arc_id") or "") == str(arc_id or ""): + return dict(arc or {}) + return {} + + def _content_quality_suggested_field_edits( + self, + *, + worldpack_payload: Dict[str, Any], + issue_code: str, + primary_asset_target: Dict[str, Any], + secondary_asset_targets: List[Dict[str, Any]], + window_label: str, + ) -> List[Dict[str, Any]]: + edits: List[Dict[str, Any]] = [] + if issue_code == "Q03": + scene_id = str(primary_asset_target.get("scene_id") or primary_asset_target.get("target_label") or "") + if scene_id: + edits.extend( + [ + { + "path": f'scene_blueprints[scene_id="{scene_id}"].quality_contract.variation_axes', + "operation": "replace", + "suggested_value": ["voice", "movement", "object_state", "information_reveal", "consequence"], + "reason": "给同一窗口里的连续章节提供更稳定的 scene-level variation 轴。", + }, + { + "path": f'scene_blueprints[scene_id="{scene_id}"].beats_template', + "operation": "rewrite", + "suggested_value": ["切换动作触发", "切换物件状态", "切换信息揭示", "切换后果落点"], + "reason": "避免连续章节围绕同一 motif 反复回声。", + }, + ] + ) + for secondary_asset_target in secondary_asset_targets: + scene_function = str(secondary_asset_target.get("scene_function") or primary_asset_target.get("scene_function") or "") + character_id = str(secondary_asset_target.get("character_id") or "") + if secondary_asset_target.get("asset_type") == "scene_realization_contracts" and scene_function: + edits.extend( + [ + { + "path": f'scene_realization_contracts["default"].scene_openings.{scene_function}', + "operation": "replace", + "suggested_value": [ + "补三组更依赖具体物件、空间状态和信息落点的 opening。", + "避免每章都用同一种抽象暗潮开场。", + ], + "reason": "让同一 scene function 在窗口内拥有更强的 opening 变体。", + }, + { + "path": f'scene_realization_contracts["default"].scene_hooks.{scene_function}', + "operation": "replace", + "suggested_value": [ + "补三组更具体的 hook,直接把下一章的问题、代价或证据推到台前。" + ], + "reason": "避免章节尾声总是靠相似的抽象回响撑住。", + }, + ] + ) + if secondary_asset_target.get("asset_type") == "emotion_action_policies" and scene_function: + edits.extend( + [ + { + "path": f'emotion_action_policies["default"].action_map.{scene_function}.entry', + "operation": "replace", + "suggested_value": [ + "补三组以手势、物件和位置变化触发的 entry 动作。" + ], + "reason": "降低窗口内 entry 动作的重复形状。", + }, + { + "path": f'emotion_action_policies["default"].action_map.{scene_function}.pressure', + "operation": "replace", + "suggested_value": [ + "补三组更具体的 pressure 动作,不再只靠抽象停顿和压场。" + ], + "reason": "让压力位更多通过动作差异推进,而不是同一句法回声。", + }, + ] + ) + if secondary_asset_target.get("asset_type") == "voice_profiles" and character_id: + edits.extend( + [ + { + "path": f'voice_profiles["{character_id}"].opening_style', + "operation": "expand", + "suggested_value": ["补一组与当前窗口主冲突不同的开场句式,避免同一章法反复开口。"], + "reason": "降低窗口内首句雷同,给 compare 面板更明显的 voice diff。", + }, + { + "path": f'voice_profiles["{character_id}"].signature_replies', + "operation": "expand", + "suggested_value": ["补一组与当前窗口主冲突不同的应答句式,减少重复回声。"], + "reason": "降低窗口内对白雷同,给 compare 面板更明显的 voice diff。", + }, + ] + ) + if secondary_asset_target.get("asset_type") == "response_cadence_profiles" and character_id: + edits.extend( + [ + { + "path": f'response_cadence_profiles["{character_id}"].reaction_lines.entry', + "operation": "expand", + "suggested_value": ["补一组更依赖动作、物件和停顿的反应句。"], + "reason": "让窗口内 reaction 不再反复使用同一种解释式停顿。", + }, + { + "path": f'response_cadence_profiles["{character_id}"].reply_lines.pressure', + "operation": "expand", + "suggested_value": ["补一组更短、更具压迫感的 pressure reply。"], + "reason": "减少压力位对白的解释感,压低 exposition ratio。", + }, + ] + ) + elif issue_code == "Q04": + scene_id = str(primary_asset_target.get("scene_id") or primary_asset_target.get("target_label") or "") + if scene_id: + edits.extend( + [ + { + "path": f'scene_blueprints[scene_id="{scene_id}"].quality_contract.dialogue_pressure', + "operation": "replace", + "suggested_value": "high", + "reason": "提高 scene-facing dialogue pressure,减少解释句比重。", + }, + { + "path": f'scene_blueprints[scene_id="{scene_id}"].quality_contract.detail_anchor_types', + "operation": "replace", + "suggested_value": ["object", "sound", "body_motion", "ambient_signal", "object_state"], + "reason": "强制每章用物件/声响/身体动作承接情绪,而不是只靠解释。", + }, + ] + ) + chapter_task_id = str(primary_asset_target.get("chapter_task_id") or "") + arc_id = str(primary_asset_target.get("arc_id") or "") + if chapter_task_id and arc_id: + edits.append( + { + "path": f'arc_plans[arc_id="{arc_id}"].chapter_tasks[chapter_task_id="{chapter_task_id}"].quality_contract.max_exposition_ratio', + "operation": "replace", + "suggested_value": 0.48, + "reason": "收紧当前窗口任务允许的 exposition 上限。", + } + ) + for secondary_asset_target in secondary_asset_targets: + scene_function = str(secondary_asset_target.get("scene_function") or primary_asset_target.get("scene_function") or "") + character_id = str(secondary_asset_target.get("character_id") or "") + if secondary_asset_target.get("asset_type") == "scene_realization_contracts" and scene_function: + edits.extend( + [ + { + "path": f'scene_realization_contracts["default"].scene_openings.{scene_function}', + "operation": "replace", + "suggested_value": [ + "补三组更具体的 opening,把物件、位置、关系债直接推到眼前。" + ], + "reason": "压低 opening 里的说明性总结句。", + }, + { + "path": f'scene_realization_contracts["default"].scene_hooks.{scene_function}', + "operation": "replace", + "suggested_value": [ + "补三组更直接的 hook,减少抽象的情绪总结。", + ], + "reason": "让章节结尾更像下一步动作,而不是解释性收束。", + }, + ] + ) + if secondary_asset_target.get("asset_type") == "emotion_action_policies" and scene_function: + edits.extend( + [ + { + "path": f'emotion_action_policies["default"].action_map.{scene_function}.pressure', + "operation": "replace", + "suggested_value": [ + "补三组更短、更具体的 pressure 动作。" + ], + "reason": "让压力位先靠动作落地,再让对白补足,不靠说明句撑场。" + }, + { + "path": f'emotion_action_policies["default"].action_map.{scene_function}.pivot', + "operation": "replace", + "suggested_value": [ + "补三组通过物件、身体反应和空间位移完成 pivot 的动作。" + ], + "reason": "减少 pivot 位的解释性转折句。" + }, + ] + ) + if secondary_asset_target.get("asset_type") == "character_card" and character_id: + edits.extend( + [ + { + "path": f'characters[character_id="{character_id}"].wound_profile.defense_style', + "operation": "rewrite", + "suggested_value": "让动作、拒答和场面压力承担冲突,不靠长段解释维持。", + "reason": "如果角色卡本身把冲突压成说明,就会持续推高 Q04。", + }, + { + "path": f'characters[character_id="{character_id}"].speech_traits', + "operation": "replace", + "suggested_value": ["短句", "拒答", "先让动作承接情绪"], + "reason": "让角色说话更短、更含蓄,减少解释句惯性。", + }, + { + "path": f'characters[character_id="{character_id}"].action_traits', + "operation": "replace", + "suggested_value": ["先停手", "看物件", "用动作把话堵回去"], + "reason": "把情绪和冲突压到动作上,而不是直接说透。", + }, + ] + ) + if secondary_asset_target.get("asset_type") == "response_cadence_profiles" and character_id: + edits.extend( + [ + { + "path": f'response_cadence_profiles["{character_id}"].reaction_lines.pressure', + "operation": "expand", + "suggested_value": ["补一组更短、少解释、更多动作停顿的 pressure 反应句。"], + "reason": "让对白压力位更依赖反应而不是解释。", + }, + { + "path": f'response_cadence_profiles["{character_id}"].reply_lines.pivot', + "operation": "expand", + "suggested_value": ["补一组用拒答、反问、短促承认来完成 pivot 的 reply。"], + "reason": "减少 pivot 位的说明性总结句。", + }, + ] + ) + elif issue_code == "Q05": + scene_id = str(primary_asset_target.get("scene_id") or primary_asset_target.get("target_label") or "") + if scene_id: + edits.extend( + [ + { + "path": f'scene_blueprints[scene_id="{scene_id}"].quality_contract.detail_anchor_types', + "operation": "replace", + "suggested_value": ["object", "sound", "body_motion", "ambient_signal", "object_state"], + "reason": "让同类场景稳定输出可感知的物件、声响、身体动作和环境细节。", + }, + { + "path": f'scene_blueprints[scene_id="{scene_id}"].beats_template', + "operation": "rewrite", + "suggested_value": ["物件状态变化", "空间细节落地", "身体动作承压", "余波追上来"], + "reason": "避免章节只剩结论和说明,把细节落到 beat 级别。", + }, + ] + ) + chapter_task_id = str(primary_asset_target.get("chapter_task_id") or "") + arc_id = str(primary_asset_target.get("arc_id") or "") + if chapter_task_id and arc_id: + edits.append( + { + "path": f'arc_plans[arc_id="{arc_id}"].chapter_tasks[chapter_task_id="{chapter_task_id}"].quality_contract.min_detail_density', + "operation": "replace", + "suggested_value": 0.045, + "reason": "把当前任务的最小 detail density 抬到 200 章诊断合同标准。", + } + ) + elif issue_code == "Q09": + chapter_task_id = str(primary_asset_target.get("chapter_task_id") or "") + arc_id = str(primary_asset_target.get("arc_id") or "") + if chapter_task_id and arc_id: + edits.extend( + [ + { + "path": f'arc_plans[arc_id="{arc_id}"].chapter_tasks[chapter_task_id="{chapter_task_id}"].quality_contract.continuation_pressure_required', + "operation": "replace", + "suggested_value": True, + "reason": "强制当前任务结尾必须推出下一章问题。", + }, + { + "path": f'arc_plans[arc_id="{arc_id}"].chapter_tasks[chapter_task_id="{chapter_task_id}"].quality_contract.delayed_payoff_window', + "operation": "replace", + "suggested_value": {"min_chapters": 1, "max_chapters": 4}, + "reason": "把 payoff 拉回更近窗口,减少 late 窗口掉速。", + }, + { + "path": f'arc_plans[arc_id="{arc_id}"].chapter_tasks[chapter_task_id="{chapter_task_id}"].allow_terminal', + "operation": "replace", + "suggested_value": False, + "reason": "在非最终收束阶段收紧 premature terminal。", + }, + { + "path": f'arc_plans[arc_id="{arc_id}"].chapter_tasks[chapter_task_id="{chapter_task_id}"].objective', + "operation": "rewrite", + "suggested_value": f"保持 {window_label} 窗口的 continuation pressure,结尾必须把下一章问题推出去。", + "reason": "让任务目标直接承担 Q09 修复,而不是留给 writer 自行解释。", + }, + ] + ) + arc_payload = self._find_arc_payload(worldpack_payload, arc_id) + arc_promise_ids = [ + str(item.get("promise_id") or "") + for item in list(arc_payload.get("arc_promises") or []) + if str(item.get("promise_id") or "") + ] + if arc_promise_ids: + edits.append( + { + "path": f'arc_plans[arc_id="{arc_id}"].chapter_tasks[chapter_task_id="{chapter_task_id}"].promise_targets', + "operation": "replace", + "suggested_value": arc_promise_ids, + "reason": "让当前任务直接绑定本弧 promises,减少后段没有可追账目标的空转。", + } + ) + edits.append( + { + "path": f'arc_plans[arc_id="{arc_id}"].chapter_tasks[chapter_task_id="{chapter_task_id}"].notes', + "operation": "append", + "suggested_value": f"contract_q09_repair_window={window_label}", + "reason": "把这次 repair campaign 的窗口语义回写到任务备注里,便于 compare/task_linking 复盘。", + } + ) + secondary_arc_id = str((secondary_asset_targets[0] if secondary_asset_targets else {}).get("arc_id") or "") + if secondary_arc_id: + edits.append( + { + "path": f'arc_plans[arc_id="{secondary_arc_id}"].completion_conditions', + "operation": "replace", + "suggested_value": ["main_conflict_shifted", "new_debt_or_promise_opened", "next_chapter_hook_intensified"], + "reason": "让同弧多章触发 Q09 时,弧线计划本身也承担 hook 义务。", + } + ) + return edits + + def _content_quality_suggested_actions( + self, + *, + issue_code: str, + window_label: str, + targeted_chapter_indices: List[int], + secondary_asset_targets: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + if issue_code == "Q03": + actions = [ + { + "action": "expand_scene_variation_axes", + "summary": "扩 scene-level variation 轴,优先打散连续章节里的同一 motif 回声。", + "details": f"窗口 {window_label} · 章节 {targeted_chapter_indices[:6]}", + }, + { + "action": "rewrite_beats_for_rotation", + "summary": "重写 beats_template,让动作/物件/信息揭示在连续章节里轮换。", + "details": "不要让同一 scene family 连续用同一种 entry-pressure-pivot 形状。", + }, + ] + for secondary_asset_target in secondary_asset_targets: + if secondary_asset_target.get("asset_type") == "scene_realization_contracts": + actions.append( + { + "action": "diversify_scene_realization_contracts", + "summary": "为目标 scene function 扩 opening / hook 变体,减少抽象性重复开场和结尾。", + "details": str(secondary_asset_target.get("target_label") or ""), + } + ) + if secondary_asset_target.get("asset_type") == "emotion_action_policies": + actions.append( + { + "action": "diversify_emotion_action_policies", + "summary": "为目标 scene function 扩 entry / pressure 动作变体,让重复不再只靠对白承接。", + "details": str(secondary_asset_target.get("target_label") or ""), + } + ) + if secondary_asset_target.get("asset_type") == "voice_profiles": + actions.append( + { + "action": "differentiate_voice_profiles", + "summary": "为目标角色补一组不重复的 signature replies / openings。", + "details": str(secondary_asset_target.get("target_label") or ""), + } + ) + if secondary_asset_target.get("asset_type") == "response_cadence_profiles": + actions.append( + { + "action": "diversify_response_cadence", + "summary": "为目标角色补一组 entry / pressure 的 reaction 和 reply 节奏变体。", + "details": str(secondary_asset_target.get("target_label") or ""), + } + ) + return actions + if issue_code == "Q04": + actions = [ + { + "action": "raise_dialogue_pressure", + "summary": "提高 scene 的 dialogue pressure,让冲突更多通过对白和动作推进。", + "details": f"窗口 {window_label} · 章节 {targeted_chapter_indices[:6]}", + }, + { + "action": "expand_detail_anchors", + "summary": "扩 detail anchors,用物件/声响/身体动作承接情绪。", + "details": "优先压低 exposition ratio,而不是只补字数。", + }, + ] + for secondary_asset_target in secondary_asset_targets: + if secondary_asset_target.get("asset_type") == "scene_realization_contracts": + actions.append( + { + "action": "tighten_scene_realization_contracts", + "summary": "把 opening / hook 从说明句改成更具体的物件、位置与后果推进。", + "details": str(secondary_asset_target.get("target_label") or ""), + } + ) + if secondary_asset_target.get("asset_type") == "emotion_action_policies": + actions.append( + { + "action": "concretize_emotion_action_policies", + "summary": "让 pressure / pivot 位先靠动作落地,再由对白补足冲突。", + "details": str(secondary_asset_target.get("target_label") or ""), + } + ) + if secondary_asset_target.get("asset_type") == "character_card": + actions.append( + { + "action": "tighten_character_card", + "summary": "检查目标角色的 vow / wound 是否在逼章节靠解释维持。", + "details": str(secondary_asset_target.get("target_label") or ""), + } + ) + if secondary_asset_target.get("asset_type") == "response_cadence_profiles": + actions.append( + { + "action": "shorten_response_cadence", + "summary": "收紧目标角色的 reaction / reply 节奏,让说明句退到动作后面。", + "details": str(secondary_asset_target.get("target_label") or ""), + } + ) + return actions + return [ + { + "action": "tighten_continuation_pressure", + "summary": "强制任务结尾推出下一章问题,禁止后段无钩子收束。", + "details": f"窗口 {window_label} · 章节 {targeted_chapter_indices[:6]}", + }, + { + "action": "rebalance_delayed_payoff", + "summary": "把 payoff 拉回更近窗口,并收紧 allow_terminal。", + "details": "优先减少 late window 的 Q09 breach。", + }, + { + "action": "raise_arc_level_hook_obligation", + "summary": "同弧多章持续触发 Q09 时,把 hook 义务提升到 arc plan。", + "details": str((secondary_asset_targets[0] if secondary_asset_targets else {}).get("target_label") or ""), + }, + ] + + def _build_content_quality_repair_workbench( + self, + worldpack_payload: Dict[str, Any], + simulation_report: Dict[str, Any], + ) -> Dict[str, Any]: + if not simulation_report: + return {"available": False, "windows": {}, "default_campaign": {}, "campaigns": [], "next_actions": []} + window_metrics = self._content_quality_window_metrics(worldpack_payload, simulation_report) + if not bool(window_metrics.get("enabled")): + return {"available": False, "windows": {}, "default_campaign": {}, "campaigns": [], "next_actions": []} + creative_cockpit = dict( + simulation_report.get("creative_cockpit") + or self._build_creative_cockpit(worldpack_payload, simulation_report) + ) + chapter_heatmap = [dict(item) for item in list((creative_cockpit.get("chapter_heatmap") or {}).get("chapters") or [])] + issue_priority_groups = { + str(item.get("issue_code") or ""): dict(item) + for item in list((creative_cockpit.get("chapter_heatmap") or {}).get("issue_priority_groups") or []) + if str(item.get("issue_code") or "") + } + target_chapters = int( + ((simulation_report.get("longform_plan_snapshot") or {}).get("series_plan") or {}).get("total_chapter_target") + or ((worldpack_payload.get("series_plan") or {}).get("total_chapter_target") or 0) + or simulation_report.get("chapter_budget") + or 0 + ) + contract_failed_chapters = [dict(item) for item in list(window_metrics.get("contract_failed_chapters") or [])] + campaign_order = {"late": 0, "mid": 1, "early": 2} + issue_order = {"Q09": 0, "Q04": 1, "Q03": 2} + campaigns: List[Dict[str, Any]] = [] + windows_payload: Dict[str, Any] = {} + + for window_label in ("early", "mid", "late"): + window_range = self._content_quality_window_range(target_chapters=target_chapters, window_label=window_label) + start = int(window_range.get("start", 0) or 0) + end = int(window_range.get("end", 0) or 0) + window_failed = [ + item + for item in contract_failed_chapters + if start <= int(item.get("chapter_index", 0) or 0) <= end + ] + window_issue_codes = [] + for item in window_failed: + for issue_code in self._contract_issue_codes_from_failed_checks(list(item.get("failed_checks") or [])): + if issue_code in {"Q03", "Q04", "Q09"} and issue_code not in window_issue_codes: + window_issue_codes.append(issue_code) + metric_key = { + "early": "early_window_q03_q04_share", + "mid": "mid_window_repeat_breach_rate", + "late": "late_window_q09_breach_rate", + }.get(window_label, "") + windows_payload[window_label] = { + "window_label": window_label, + "window_range": {"start": start, "end": end}, + "metrics": { + "early_window_q03_q04_share": float(window_metrics.get("early_window_q03_q04_share", 0.0) or 0.0), + "mid_window_repeat_breach_rate": float(window_metrics.get("mid_window_repeat_breach_rate", 0.0) or 0.0), + "mid_window_exposition_breach_rate": float(window_metrics.get("mid_window_exposition_breach_rate", 0.0) or 0.0), + "late_window_q09_breach_rate": float(window_metrics.get("late_window_q09_breach_rate", 0.0) or 0.0), + }, + "thresholds": dict(window_metrics.get("thresholds") or {}), + "failed_chapter_count": len(window_failed), + "issue_codes": window_issue_codes, + "primary_metric_key": metric_key, + } + for issue_code in window_issue_codes: + targeted_chapters = [ + dict(item) + for item in chapter_heatmap + if start <= int(item.get("chapter_index", 0) or 0) <= end + and issue_code in list(item.get("issue_codes") or []) + ] + issue_failed = [ + item + for item in window_failed + if issue_code in self._contract_issue_codes_from_failed_checks(list(item.get("failed_checks") or [])) + ] + targeted_indices = sorted( + { + int(item.get("chapter_index", 0) or 0) + for item in issue_failed + if int(item.get("chapter_index", 0) or 0) > 0 + } + or { + int(item.get("chapter_index", 0) or 0) + for item in targeted_chapters + if int(item.get("chapter_index", 0) or 0) > 0 + } + ) + if not targeted_indices: + continue + baseline_worst_decision = max( + (str(item.get("decision") or "pass") for item in issue_failed), + key=self._decision_severity, + default=max( + (str(item.get("decision") or "pass") for item in targeted_chapters), + key=self._decision_severity, + default="pass", + ), + ) + average_score = round( + sum(float(item.get("overall_score", 0.0) or 0.0) for item in targeted_chapters) / float(max(1, len(targeted_chapters))), + 3, + ) + primary_asset_target = self._content_quality_primary_asset_target( + issue_code=issue_code, + targeted_chapters=targeted_chapters, + ) + secondary_asset_targets = self._content_quality_secondary_asset_targets( + worldpack_payload=worldpack_payload, + issue_code=issue_code, + targeted_chapters=targeted_chapters, + primary_asset_target=primary_asset_target, + ) + breach_kind = { + ("early", "Q03"): "early_window_q03_q04_share", + ("early", "Q04"): "early_window_q03_q04_share", + ("mid", "Q03"): "mid_window_repeat_breach_rate", + ("mid", "Q04"): "mid_window_exposition_breach_rate", + ("late", "Q09"): "late_window_q09_breach_rate", + }.get((window_label, issue_code), str((issue_failed[0].get("failed_checks") or [""])[0] if issue_failed else "")) + suggested_field_edits = self._content_quality_suggested_field_edits( + worldpack_payload=worldpack_payload, + issue_code=issue_code, + primary_asset_target=primary_asset_target, + secondary_asset_targets=secondary_asset_targets, + window_label=window_label, + ) + suggested_actions = self._content_quality_suggested_actions( + issue_code=issue_code, + window_label=window_label, + targeted_chapter_indices=targeted_indices, + secondary_asset_targets=secondary_asset_targets, + ) + strategy_bundle = build_strategy_bundle( + issue_codes=([issue_code] + [item for item in window_issue_codes if item != issue_code]), + window_label=window_label, + primary_asset_target=primary_asset_target, + secondary_asset_targets=secondary_asset_targets, + suggested_actions=suggested_actions, + suggested_field_edits=suggested_field_edits, + targeted_chapter_indices=targeted_indices, + ) + group = dict(issue_priority_groups.get(issue_code) or {}) + repair_loop_context = { + "issue_code": issue_code, + "issue_label": ISSUE_TAXONOMY.get(issue_code, {}).get("label", issue_code), + "asset_type": primary_asset_target.get("asset_type", ""), + "asset_label": primary_asset_target.get("asset_label", ""), + "target_label": primary_asset_target.get("target_label", ""), + "validation_panel": primary_asset_target.get("validation_panel", ""), + "validation_panel_label": primary_asset_target.get("validation_panel_label", ""), + "window_label": window_label, + "window_range_start": start, + "window_range_end": end, + "window_breach_kind": breach_kind, + "baseline_issue_count": len(issue_failed), + "baseline_worst_decision": baseline_worst_decision, + "targeted_chapters": [ + { + "chapter_index": index, + "chapter_title": next( + (item.get("chapter_title", "") for item in targeted_chapters if int(item.get("chapter_index", 0) or 0) == index), + "", + ), + } + for index in targeted_indices + ], + "targeted_chapter_indices": targeted_indices, + "contract_failed_checks": sorted( + { + str(name) + for item in issue_failed + for name in list(item.get("failed_checks") or []) + if str(name) + } + ), + "scene_id": primary_asset_target.get("scene_id", ""), + "scene_function": primary_asset_target.get("scene_function", ""), + "chapter_task_id": primary_asset_target.get("chapter_task_id", ""), + "arc_id": primary_asset_target.get("arc_id", ""), + "volume_id": primary_asset_target.get("volume_id", ""), + } + campaigns.append( + { + "campaign_id": f"content_quality::{window_label}::{issue_code}", + "window_label": window_label, + "window_range": {"start": start, "end": end}, + "issue_code": issue_code, + "issue_label": ISSUE_TAXONOMY.get(issue_code, {}).get("label", issue_code), + "breach_kind": breach_kind, + "targeted_chapter_indices": targeted_indices, + "baseline_issue_count": len(issue_failed), + "baseline_worst_decision": baseline_worst_decision, + "failed_chapter_count": len(issue_failed), + "average_score": average_score, + "primary_asset_type": primary_asset_target.get("asset_type", ""), + "primary_asset_target": primary_asset_target, + "secondary_asset_target": dict(secondary_asset_targets[0]) if secondary_asset_targets else {}, + "secondary_asset_targets": secondary_asset_targets, + "validation_panel": primary_asset_target.get("validation_panel", ""), + "validation_panel_label": primary_asset_target.get("validation_panel_label", ""), + "suggested_actions": suggested_actions, + "suggested_field_edits": suggested_field_edits, + "strategy_bundle_id": strategy_bundle.get("strategy_bundle_id", ""), + "strategy_bundle": strategy_bundle, + "current_window_metrics": { + **dict(window_metrics.get("thresholds") or {}), + "early_window_q03_q04_share": float(window_metrics.get("early_window_q03_q04_share", 0.0) or 0.0), + "mid_window_repeat_breach_rate": float(window_metrics.get("mid_window_repeat_breach_rate", 0.0) or 0.0), + "mid_window_exposition_breach_rate": float(window_metrics.get("mid_window_exposition_breach_rate", 0.0) or 0.0), + "late_window_q09_breach_rate": float(window_metrics.get("late_window_q09_breach_rate", 0.0) or 0.0), + }, + "rerun_scope": { + "mode": "full_100_rerun", + "reason": "longform_state_dependency", + "focus_window": window_label, + "compare_mode": "window_slice", + }, + "repair_loop_context": repair_loop_context, + "group_primary_asset_type": group.get("primary_asset_type", ""), + } + ) + + if not campaigns: + drilldown = self._build_simulation_drilldown(simulation_report) + quality_histogram = [dict(item) for item in list((drilldown.get("quality_pass_summary") or {}).get("action_histogram") or [])] + q03_action_count = sum( + int(item.get("count", 0) or 0) + for item in quality_histogram + if str(item.get("action") or "").startswith("q03_") + ) + if q03_action_count > 0: + targeted_chapters = [ + { + **dict(item), + "issue_count": 1, + "scene_id": str(item.get("scene_id") or ""), + "scene_function": str(item.get("scene_function") or ""), + } + for item in list(drilldown.get("weakest_chapters") or drilldown.get("chapter_breakdown") or [])[:3] + ] + targeted_indices = [ + int(item.get("chapter_index", 0) or 0) + for item in targeted_chapters + if int(item.get("chapter_index", 0) or 0) > 0 + ] + if targeted_indices: + issue_code = "Q03" + window_label = "early" if max(targeted_indices) <= max(1, int(target_chapters * 0.35 or 1)) else "mid" + window_range = self._content_quality_window_range(target_chapters=target_chapters, window_label=window_label) + primary_asset_target = self._content_quality_primary_asset_target( + issue_code=issue_code, + targeted_chapters=targeted_chapters, + ) + secondary_asset_targets = self._content_quality_secondary_asset_targets( + worldpack_payload=worldpack_payload, + issue_code=issue_code, + targeted_chapters=targeted_chapters, + primary_asset_target=primary_asset_target, + ) + suggested_field_edits = self._content_quality_suggested_field_edits( + worldpack_payload=worldpack_payload, + issue_code=issue_code, + primary_asset_target=primary_asset_target, + secondary_asset_targets=secondary_asset_targets, + window_label=window_label, + ) + suggested_actions = self._content_quality_suggested_actions( + issue_code=issue_code, + window_label=window_label, + targeted_chapter_indices=targeted_indices, + secondary_asset_targets=secondary_asset_targets, + ) + strategy_bundle = build_strategy_bundle( + issue_codes=[issue_code], + window_label=window_label, + primary_asset_target=primary_asset_target, + secondary_asset_targets=secondary_asset_targets, + suggested_actions=suggested_actions, + suggested_field_edits=suggested_field_edits, + targeted_chapter_indices=targeted_indices, + ) + repair_loop_context = { + "issue_code": issue_code, + "issue_label": ISSUE_TAXONOMY.get(issue_code, {}).get("label", issue_code), + "asset_type": primary_asset_target.get("asset_type", ""), + "asset_label": primary_asset_target.get("asset_label", ""), + "target_label": primary_asset_target.get("target_label", ""), + "validation_panel": primary_asset_target.get("validation_panel", ""), + "validation_panel_label": primary_asset_target.get("validation_panel_label", ""), + "window_label": window_label, + "window_range_start": int(window_range.get("start", 0) or 0), + "window_range_end": int(window_range.get("end", 0) or 0), + "window_breach_kind": "quality_pass_q03_repair_burden", + "baseline_issue_count": 0, + "baseline_worst_decision": "pass", + "baseline_quality_pass_q03_action_count": q03_action_count, + "preventive_quality_pass_campaign": True, + "targeted_chapters": [ + { + "chapter_index": int(item.get("chapter_index", 0) or 0), + "chapter_title": item.get("chapter_title", ""), + } + for item in targeted_chapters + ], + "targeted_chapter_indices": targeted_indices, + "contract_failed_checks": ["quality_pass_q03_repair_burden"], + "scene_id": primary_asset_target.get("scene_id", ""), + "scene_function": primary_asset_target.get("scene_function", ""), + "chapter_task_id": primary_asset_target.get("chapter_task_id", ""), + "arc_id": primary_asset_target.get("arc_id", ""), + "volume_id": primary_asset_target.get("volume_id", ""), + } + campaigns.append( + { + "campaign_id": f"content_quality::{window_label}::{issue_code}:quality_pass_preventive", + "window_label": window_label, + "window_range": {"start": int(window_range.get("start", 0) or 0), "end": int(window_range.get("end", 0) or 0)}, + "issue_code": issue_code, + "issue_label": ISSUE_TAXONOMY.get(issue_code, {}).get("label", issue_code), + "breach_kind": "quality_pass_q03_repair_burden", + "targeted_chapter_indices": targeted_indices, + "baseline_issue_count": 0, + "baseline_worst_decision": "pass", + "failed_chapter_count": 0, + "average_score": round( + sum(float(item.get("overall_score", 0.0) or 0.0) for item in targeted_chapters) / float(max(1, len(targeted_chapters))), + 3, + ), + "primary_asset_type": primary_asset_target.get("asset_type", ""), + "primary_asset_target": primary_asset_target, + "secondary_asset_target": dict(secondary_asset_targets[0]) if secondary_asset_targets else {}, + "secondary_asset_targets": secondary_asset_targets, + "validation_panel": primary_asset_target.get("validation_panel", ""), + "validation_panel_label": primary_asset_target.get("validation_panel_label", ""), + "suggested_actions": suggested_actions, + "suggested_field_edits": suggested_field_edits, + "strategy_bundle_id": strategy_bundle.get("strategy_bundle_id", ""), + "strategy_bundle": strategy_bundle, + "current_window_metrics": { + **dict(window_metrics.get("thresholds") or {}), + "quality_pass_q03_action_count": q03_action_count, + }, + "rerun_scope": { + "mode": "full_100_rerun", + "reason": "quality_pass_repair_burden", + "focus_window": window_label, + "compare_mode": "window_slice", + }, + "repair_loop_context": repair_loop_context, + "group_primary_asset_type": "", + } + ) + + campaigns = sorted( + campaigns, + key=lambda item: ( + campaign_order.get(str(item.get("window_label") or ""), 9), + issue_order.get(str(item.get("issue_code") or ""), 9), + -int(item.get("failed_chapter_count", 0) or 0), + -self._decision_severity(str(item.get("baseline_worst_decision") or "pass")), + float(item.get("average_score", 0.0) or 0.0), + ), + ) + for window_label in windows_payload: + windows_payload[window_label]["campaign_count"] = sum( + 1 for item in campaigns if str(item.get("window_label") or "") == window_label + ) + default_campaign = dict(campaigns[0]) if campaigns else {} + return { + "available": bool(campaigns), + "windows": windows_payload, + "default_campaign": default_campaign, + "campaigns": campaigns, + "next_actions": ( + ["review_content_quality_campaign", "apply_repair_prefill", "save_longform_workbench", "re_simulate"] + if campaigns + else [] + ), + } + + def _latest_strategy_bundle_execution( + self, + revisions: List[Dict[str, Any]], + ) -> Dict[str, Any]: + for revision in range(len(revisions) - 1, -1, -1): + execution = dict((revisions[revision] or {}).get("strategy_bundle_execution") or {}) + if execution: + return execution + return {} + + def _strategy_bundle_execution_history(self, revisions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + history: List[Dict[str, Any]] = [] + for revision in reversed(revisions): + execution = dict(revision.get("strategy_bundle_execution") or {}) + if not execution: + continue + history.append( + { + "revision_id": revision.get("revision_id"), + "created_at": revision.get("created_at"), + "source": revision.get("source"), + "label": revision.get("label"), + "strategy_bundle_execution": execution, + } + ) + return history[:5] + + def _matching_strategy_bundle_executions( + self, + revisions: List[Dict[str, Any]], + *, + campaign_id: str, + strategy_bundle_id: str, + ) -> List[Dict[str, Any]]: + matches: List[Dict[str, Any]] = [] + for revision in reversed(revisions): + execution = dict(revision.get("strategy_bundle_execution") or {}) + if not execution: + continue + if str(execution.get("campaign_id") or "") != campaign_id: + continue + if str(execution.get("strategy_bundle_id") or "") != strategy_bundle_id: + continue + matches.append(execution) + return matches + + def _parse_strategy_edit_path(self, path: str) -> List[Dict[str, Any]]: + tokens: List[Dict[str, Any]] = [] + buffer = "" + index = 0 + while index < len(path): + current = path[index] + if current == ".": + if buffer: + tokens.append({"kind": "key", "value": buffer}) + buffer = "" + index += 1 + continue + if current == "[": + if buffer: + tokens.append({"kind": "key", "value": buffer}) + buffer = "" + closing = path.find("]", index) + if closing < 0: + return [] + raw_selector = path[index + 1 : closing] + if raw_selector.startswith('"') and raw_selector.endswith('"'): + tokens.append({"kind": "map_key", "value": raw_selector[1:-1]}) + elif "=" in raw_selector: + selector_field, selector_value = raw_selector.split("=", 1) + tokens.append( + { + "kind": "selector", + "field": selector_field.strip(), + "value": selector_value.strip().strip('"'), + } + ) + elif raw_selector.isdigit(): + tokens.append({"kind": "index", "value": int(raw_selector)}) + else: + return [] + index = closing + 1 + continue + buffer += current + index += 1 + if buffer: + tokens.append({"kind": "key", "value": buffer}) + return tokens + + def _descend_strategy_path_token( + self, + current: Any, + token: Dict[str, Any], + *, + next_token: Optional[Dict[str, Any]] = None, + ) -> tuple[Any, Optional[str]]: + token_kind = str(token.get("kind") or "") + default_child: Any = [] if str((next_token or {}).get("kind") or "") in {"selector", "index"} else {} + if token_kind == "key": + if not isinstance(current, dict): + return None, "non_dict_parent" + key = str(token.get("value") or "") + if key not in current or current[key] is None: + current[key] = copy.deepcopy(default_child) + return current[key], None + if token_kind == "map_key": + if not isinstance(current, dict): + return None, "non_dict_parent" + key = str(token.get("value") or "") + if key not in current or current[key] is None: + current[key] = copy.deepcopy(default_child) + return current[key], None + if token_kind == "selector": + if not isinstance(current, list): + return None, "non_list_parent" + selector_field = str(token.get("field") or "") + selector_value = str(token.get("value") or "") + matched = next( + ( + item + for item in current + if str(dict(item or {}).get(selector_field) or "") == selector_value + ), + None, + ) + if matched is None: + return None, "selector_target_missing" + return matched, None + if token_kind == "index": + if not isinstance(current, list): + return None, "non_list_parent" + target_index = int(token.get("value", -1) or -1) + if target_index < 0 or target_index >= len(current): + return None, "index_out_of_range" + return current[target_index], None + return None, "unsupported_token" + + def _read_strategy_path_value( + self, + parent: Any, + token: Dict[str, Any], + ) -> tuple[bool, Any]: + token_kind = str(token.get("kind") or "") + if token_kind in {"key", "map_key"} and isinstance(parent, dict): + key = str(token.get("value") or "") + return key in parent, copy.deepcopy(parent.get(key)) + if token_kind == "index" and isinstance(parent, list): + target_index = int(token.get("value", -1) or -1) + if 0 <= target_index < len(parent): + return True, copy.deepcopy(parent[target_index]) + return False, None + if token_kind == "selector" and isinstance(parent, list): + selector_field = str(token.get("field") or "") + selector_value = str(token.get("value") or "") + matched = next( + ( + item + for item in parent + if str(dict(item or {}).get(selector_field) or "") == selector_value + ), + None, + ) + return matched is not None, copy.deepcopy(matched) + return False, None + + def _write_strategy_path_value( + self, + parent: Any, + token: Dict[str, Any], + value: Any, + ) -> bool: + token_kind = str(token.get("kind") or "") + if token_kind in {"key", "map_key"} and isinstance(parent, dict): + parent[str(token.get("value") or "")] = copy.deepcopy(value) + return True + if token_kind == "index" and isinstance(parent, list): + target_index = int(token.get("value", -1) or -1) + if 0 <= target_index < len(parent): + parent[target_index] = copy.deepcopy(value) + return True + if token_kind == "selector" and isinstance(parent, list): + selector_field = str(token.get("field") or "") + selector_value = str(token.get("value") or "") + for index, item in enumerate(parent): + if str(dict(item or {}).get(selector_field) or "") == selector_value: + parent[index] = copy.deepcopy(value) + return True + return False + + def _strategy_edit_preview(self, value: Any) -> Any: + if isinstance(value, list): + return value[:3] + if isinstance(value, dict): + return {key: value[key] for key in list(value.keys())[:3]} + if isinstance(value, str) and len(value) > 120: + return f"{value[:117]}..." + return value + + def _apply_strategy_edit_operation( + self, + *, + existing_value: Any, + operation: str, + suggested_value: Any, + exists: bool, + ) -> Any: + if operation in {"replace", "rewrite"}: + return copy.deepcopy(suggested_value) + if operation in {"append", "expand"}: + if isinstance(existing_value, list): + next_value = list(existing_value) + additions = list(suggested_value) if isinstance(suggested_value, list) else [suggested_value] + for item in additions: + if item not in next_value: + next_value.append(copy.deepcopy(item)) + return next_value + if isinstance(existing_value, str): + additions = list(suggested_value) if isinstance(suggested_value, list) else [suggested_value] + addition_text = "\n".join(str(item) for item in additions if str(item)) + if not addition_text: + return existing_value + if addition_text in existing_value: + return existing_value + if not existing_value: + return addition_text + separator = "" if existing_value.endswith("\n") else "\n" + return f"{existing_value}{separator}{addition_text}" + if exists and existing_value not in {None, ""}: + return copy.deepcopy(suggested_value) + if isinstance(suggested_value, list): + return copy.deepcopy(suggested_value) + if operation == "append": + return copy.deepcopy(suggested_value) + return [copy.deepcopy(suggested_value)] + return copy.deepcopy(suggested_value) + + def _apply_strategy_field_edit( + self, + worldpack_payload: Dict[str, Any], + edit: Dict[str, Any], + ) -> Dict[str, Any]: + path = str(edit.get("path") or "") + operation = str(edit.get("operation") or "replace") + suggested_value = copy.deepcopy(edit.get("suggested_value")) + reason = str(edit.get("reason") or "") + tokens = self._parse_strategy_edit_path(path) + if not tokens: + return { + "path": path, + "operation": operation, + "status": "skipped", + "reason": reason, + "error": "invalid_path", + } + current: Any = worldpack_payload + for index, token in enumerate(tokens[:-1]): + current, error = self._descend_strategy_path_token( + current, + token, + next_token=tokens[index + 1], + ) + if error: + return { + "path": path, + "operation": operation, + "status": "skipped", + "reason": reason, + "error": error, + } + exists, before_value = self._read_strategy_path_value(current, tokens[-1]) + next_value = self._apply_strategy_edit_operation( + existing_value=before_value, + operation=operation, + suggested_value=suggested_value, + exists=exists, + ) + changed = before_value != next_value or not exists + if not self._write_strategy_path_value(current, tokens[-1], next_value): + return { + "path": path, + "operation": operation, + "status": "skipped", + "reason": reason, + "error": "write_failed", + } + return { + "path": path, + "operation": operation, + "status": "applied" if changed else "noop", + "reason": reason, + "before_type": type(before_value).__name__ if exists else "", + "after_type": type(next_value).__name__, + "before_preview": self._strategy_edit_preview(before_value), + "after_preview": self._strategy_edit_preview(next_value), + } + + def _apply_strategy_bundle_step( + self, + worldpack_payload: Dict[str, Any], + step: Dict[str, Any], + ) -> Dict[str, Any]: + edit_receipts = [ + self._apply_strategy_field_edit(worldpack_payload, dict(edit or {})) + for edit in list(step.get("suggested_field_edits") or []) + ] + applied_receipts = [item for item in edit_receipts if str(item.get("status") or "") == "applied"] + noop_receipts = [item for item in edit_receipts if str(item.get("status") or "") == "noop"] + skipped_receipts = [item for item in edit_receipts if str(item.get("status") or "") == "skipped"] + if applied_receipts: + status = "applied" + elif noop_receipts and not skipped_receipts: + status = "noop" + else: + status = "skipped" + return { + "step_id": str(step.get("step_id") or ""), + "apply_order": int(step.get("apply_order", 0) or 0), + "asset_type": str(step.get("asset_type") or ""), + "target": dict(step.get("target") or {}), + "validation_panel": str(step.get("validation_panel") or ""), + "validation_panel_label": str(step.get("validation_panel_label") or ""), + "status": status, + "applied_edit_count": len(applied_receipts), + "noop_edit_count": len(noop_receipts), + "skipped_edit_count": len(skipped_receipts), + "applied_paths": [str(item.get("path") or "") for item in applied_receipts], + "skipped_paths": [str(item.get("path") or "") for item in skipped_receipts], + "edit_receipts": edit_receipts, + "post_apply_validation": list(step.get("post_apply_validation") or []), + } + + def _strategy_metric_value(self, simulation_report: Dict[str, Any], metric_name: str) -> float: + for payload in ( + dict(simulation_report.get("content_quality_contract_window_metrics") or {}), + dict(simulation_report.get("longform_summary") or {}), + dict(simulation_report.get("evaluation_summary") or {}), + dict(simulation_report or {}), + ): + if metric_name in payload: + try: + return round(float(payload.get(metric_name, 0.0) or 0.0), 3) + except (TypeError, ValueError): + return 0.0 + return 0.0 + + def _build_strategy_bundle_result_attribution( + self, + *, + strategy_bundle: Dict[str, Any], + baseline_report: Dict[str, Any], + rerun_report: Dict[str, Any], + step_receipts: List[Dict[str, Any]], + latest_repair_loop_outcome: Dict[str, Any], + ) -> Dict[str, Any]: + rerun_config = dict(strategy_bundle.get("rerun_attribution") or {}) + metric_receipt: List[Dict[str, Any]] = [] + improved_metrics: List[str] = [] + regressed_metrics: List[str] = [] + flat_metrics: List[str] = [] + for metric_payload in list(rerun_config.get("metrics_to_watch") or []): + metric_name = str(metric_payload.get("metric") or "") + direction = str(metric_payload.get("direction") or "decrease") + baseline_value = self._strategy_metric_value(baseline_report, metric_name) + current_value = self._strategy_metric_value(rerun_report, metric_name) + delta = round(current_value - baseline_value, 3) + if abs(delta) <= 0.001: + status = "flat" + flat_metrics.append(metric_name) + elif (direction == "increase" and delta > 0) or (direction == "decrease" and delta < 0): + status = "improved" + improved_metrics.append(metric_name) + else: + status = "regressed" + regressed_metrics.append(metric_name) + metric_receipt.append( + { + "metric": metric_name, + "direction": direction, + "baseline": baseline_value, + "current": current_value, + "delta": delta, + "status": status, + } + ) + if improved_metrics and regressed_metrics: + overall_status = "mixed" + elif improved_metrics: + overall_status = "improved" + elif regressed_metrics: + overall_status = "regressed" + else: + overall_status = "flat" + primary_signal = next( + ( + item + for item in metric_receipt + if str(item.get("status") or "") == "improved" + ), + metric_receipt[0] if metric_receipt else {}, + ) + applied_asset_sequence = [ + str(item.get("asset_type") or "") + for item in step_receipts + if str(item.get("status") or "") == "applied" + ] + summary = ( + f"bundle rerun {overall_status}: improved={improved_metrics or []}, " + f"regressed={regressed_metrics or []}, ready_for_validation={bool(latest_repair_loop_outcome.get('ready_for_validation', False))}" + ) + return { + "available": bool(metric_receipt), + "rerun_scope": str(rerun_config.get("rerun_scope") or ""), + "compare_scope": str(rerun_config.get("compare_scope") or ""), + "window_label": str(rerun_config.get("window_label") or ""), + "attribution_rule": str(rerun_config.get("attribution_rule") or ""), + "metric_receipt": metric_receipt, + "improved_metrics": improved_metrics, + "regressed_metrics": regressed_metrics, + "flat_metrics": flat_metrics, + "overall_status": overall_status, + "primary_signal": dict(primary_signal or {}), + "candidate_contributors": applied_asset_sequence[:3], + "ready_for_validation": bool(latest_repair_loop_outcome.get("ready_for_validation", False)), + "summary": summary, + } + + def _build_strategy_bundle_stop_decision( + self, + *, + stop_condition: Dict[str, Any], + result_attribution: Dict[str, Any], + prior_executions: List[Dict[str, Any]], + latest_repair_loop_outcome: Dict[str, Any], + ) -> Dict[str, Any]: + rule_id = str(stop_condition.get("rule_id") or "") + overall_status = str(result_attribution.get("overall_status") or "flat") + ready_for_validation = bool( + latest_repair_loop_outcome.get("ready_for_validation", False) + or result_attribution.get("ready_for_validation", False) + ) + prior_flat_or_regressed = [ + item + for item in prior_executions + if str(dict(item.get("result_attribution") or {}).get("overall_status") or "") in {"flat", "regressed"} + ] + escalation_target = { + "upgrade_to_planner_or_pack_contract_if_two_reruns_flat": "planner_or_pack_contract", + "upgrade_to_task_coupling_if_flat": "task_coupling", + "upgrade_to_budget_and_task_balance_if_flat": "budget_and_task_balance", + "upgrade_to_planner_contract_if_flat": "planner_contract", + }.get(rule_id, "manual_review") + if ready_for_validation or overall_status == "improved": + return { + "decision": "stop", + "reason": "bundle_improved_window_metrics", + "tripwire": "", + "escalation_target": "", + "next_actions": ["review_compare_after_simulation", "decide_publish_or_next_bundle"], + } + if rule_id == "upgrade_to_planner_or_pack_contract_if_two_reruns_flat": + if overall_status in {"flat", "regressed"} and prior_flat_or_regressed: + return { + "decision": "escalate", + "reason": "two_reruns_flat_or_regressed", + "tripwire": str(stop_condition.get("tripwire") or ""), + "escalation_target": escalation_target, + "next_actions": ["escalate_bundle_scope", "open_planner_or_pack_contract_fix"], + } + return { + "decision": "continue", + "reason": "first_flat_rerun", + "tripwire": "", + "escalation_target": "", + "next_actions": ["run_next_bundle_pass", "inspect_compare_panel"], + } + if overall_status in {"flat", "regressed"}: + return { + "decision": "escalate", + "reason": "stop_condition_flat_triggered", + "tripwire": str(stop_condition.get("tripwire") or ""), + "escalation_target": escalation_target, + "next_actions": ["escalate_bundle_scope", "inspect_next_strategy_layer"], + } + return { + "decision": "continue", + "reason": "mixed_signal_requires_followup", + "tripwire": "", + "escalation_target": "", + "next_actions": ["inspect_metric_receipt", "run_followup_bundle_pass"], + } + + def _strategy_bundle_ready_for_validation_override( + self, + *, + metadata: Dict[str, Any], + simulation_report: Dict[str, Any], + execution_receipt: Dict[str, Any], + ) -> Dict[str, Any]: + result_attribution = dict(execution_receipt.get("result_attribution") or {}) + repair_outcome = dict(simulation_report.get("latest_repair_loop_outcome") or {}) + receipt_outcome = dict(execution_receipt.get("repair_loop_outcome") or {}) + repair_loop_context = dict(execution_receipt.get("repair_loop_context") or {}) + if not repair_outcome and receipt_outcome: + repair_outcome = dict(receipt_outcome) + + def _count_improved(prefix: str) -> bool: + try: + baseline = int(repair_outcome.get(f"baseline_{prefix}_issue_count", 0) or 0) + current = int(repair_outcome.get(f"current_{prefix}_issue_count", 0) or 0) + except (TypeError, ValueError): + return False + return baseline > 0 and current < baseline + + severity_trend = str(repair_outcome.get("severity_trend") or receipt_outcome.get("severity_trend") or "") + preventive_quality_pass_improved = bool( + repair_loop_context.get("preventive_quality_pass_campaign") + and str(result_attribution.get("overall_status") or "") != "regressed" + and not list(result_attribution.get("regressed_metrics") or []) + ) + issue_or_severity_improved = bool( + _count_improved("targeted") + or _count_improved("window") + or severity_trend in {"improved", "resolved"} + or preventive_quality_pass_improved + or ( + str(result_attribution.get("overall_status") or "") == "improved" + and not list(result_attribution.get("regressed_metrics") or []) + ) + ) + freshness = self._simulation_freshness(metadata, simulation_report) + compare = self._build_before_after_chapter_compare(metadata) + revision_compare = self._build_revision_compare(metadata, simulation_report) + block_rate = float((simulation_report.get("evaluation_summary") or {}).get("block_rate", 0.0) or 0.0) + latest_decision = str(simulation_report.get("latest_decision") or "").lower() + ready = bool( + int(execution_receipt.get("applied_edit_count", 0) or 0) > 0 + and (bool(compare.get("available")) or bool(revision_compare.get("available"))) + and issue_or_severity_improved + and freshness.get("status") == "fresh" + and block_rate <= 0.0 + and latest_decision not in {"block", "blocked", "failed"} + ) + return { + "ready": ready, + "issue_or_severity_improved": issue_or_severity_improved, + "preventive_quality_pass_improved": preventive_quality_pass_improved, + "simulation_freshness": freshness, + "compare_available": bool(compare.get("available")), + "revision_compare_available": bool(revision_compare.get("available")), + "no_new_hard_blocker": block_rate <= 0.0 and latest_decision not in {"block", "blocked", "failed"}, + } + + def _attach_strategy_bundle_execution( + self, + *, + world_version_id: str, + revision_id: str, + execution_receipt: Dict[str, Any], + ) -> Dict[str, Any]: + version = self.repository.get_world_version(world_version_id) + metadata = self._ensure_metadata(version.worldpack_json) + revision_history = list(metadata.get("revision_history") or []) + for revision in revision_history: + if str(revision.get("revision_id") or "") == revision_id: + revision["strategy_bundle_execution"] = copy.deepcopy(execution_receipt) + break + metadata["revision_history"] = revision_history[-10:] + version.worldpack_json["metadata"] = metadata + simulation_report = dict(version.simulation_report_json or {}) + ready_override = self._strategy_bundle_ready_for_validation_override( + metadata=metadata, + simulation_report=simulation_report, + execution_receipt=execution_receipt, + ) + if bool(ready_override.get("ready")): + repair_outcome = dict(simulation_report.get("latest_repair_loop_outcome") or {}) + receipt_outcome = dict(execution_receipt.get("repair_loop_outcome") or {}) + repair_outcome.update( + { + "available": True, + "ready_for_validation": True, + "severity_trend": repair_outcome.get("severity_trend") or receipt_outcome.get("severity_trend") or "improved", + "ready_for_validation_reason": "strategy_bundle_effective_after_fresh_rerun", + "strategy_bundle_execution_id": execution_receipt.get("execution_id"), + "strategy_bundle_id": execution_receipt.get("strategy_bundle_id"), + "validation_evidence": { + "repair_receipt_present": True, + "before_after_delta_visible": bool(ready_override.get("compare_available") or ready_override.get("revision_compare_available")), + "issue_or_severity_improved": bool(ready_override.get("issue_or_severity_improved")), + "preventive_quality_pass_improved": bool(ready_override.get("preventive_quality_pass_improved")), + "simulation_freshness": dict(ready_override.get("simulation_freshness") or {}), + "no_new_hard_blocker": bool(ready_override.get("no_new_hard_blocker")), + }, + } + ) + simulation_report["latest_repair_loop_outcome"] = repair_outcome + result_attribution = dict(execution_receipt.get("result_attribution") or {}) + result_attribution["ready_for_validation"] = True + execution_receipt["result_attribution"] = result_attribution + execution_receipt["repair_loop_outcome"] = { + **receipt_outcome, + "ready_for_validation": True, + "severity_trend": repair_outcome.get("severity_trend") or "improved", + "validation_evidence": repair_outcome.get("validation_evidence"), + } + stop_decision = dict(execution_receipt.get("stop_decision") or {}) + if str(stop_decision.get("decision") or "") != "stop": + execution_receipt["stop_decision"] = { + **stop_decision, + "decision": "stop", + "reason": "strategy_bundle_ready_for_validation", + "next_actions": ["review_compare_after_simulation", "submit_for_review"], + } + for revision in revision_history: + if str(revision.get("revision_id") or "") == revision_id: + revision["repair_loop_outcome"] = copy.deepcopy(repair_outcome) + revision["strategy_bundle_execution"] = copy.deepcopy(execution_receipt) + break + metadata["revision_history"] = revision_history[-10:] + version.worldpack_json["metadata"] = metadata + simulation_report["latest_strategy_bundle_execution"] = copy.deepcopy(execution_receipt) + simulation_report["strategy_bundle_execution_history"] = self._strategy_bundle_execution_history(revision_history) + version.simulation_report_json = simulation_report + self.repository.save_world_version(version, publish=False) + return { + "latest_strategy_bundle_execution": copy.deepcopy(execution_receipt), + "strategy_bundle_execution_history": simulation_report["strategy_bundle_execution_history"], + } + + def execute_content_quality_strategy_bundle( + self, + world_version_id: str, + *, + campaign_id: Optional[str] = None, + ) -> Dict[str, Any]: + version = self.repository.get_world_version(world_version_id) + worldpack_payload = copy.deepcopy(version.worldpack_json or {}) + baseline_report = copy.deepcopy(version.simulation_report_json or {}) + workbench = self._build_content_quality_repair_workbench(worldpack_payload, baseline_report) + if not bool(workbench.get("available")): + raise ValueError("content_quality_strategy_bundle_unavailable") + campaigns = [dict(item or {}) for item in list(workbench.get("campaigns") or [])] + selected_campaign = next( + ( + item + for item in campaigns + if str(item.get("campaign_id") or "") == str(campaign_id or "") + ), + dict(workbench.get("default_campaign") or {}), + ) + if not selected_campaign: + raise ValueError("content_quality_strategy_bundle_campaign_not_found") + strategy_bundle = dict(selected_campaign.get("strategy_bundle") or {}) + if not bool(strategy_bundle.get("execution_protocol_enabled")): + raise ValueError("content_quality_strategy_bundle_execution_disabled") + strategy_bundle_id = str(strategy_bundle.get("strategy_bundle_id") or "") + selected_campaign_id = str(selected_campaign.get("campaign_id") or "") + previous_executions = self._matching_strategy_bundle_executions( + list((worldpack_payload.get("metadata") or {}).get("revision_history") or []), + campaign_id=selected_campaign_id, + strategy_bundle_id=strategy_bundle_id, + ) + revision_id = "" + + def _persistent_simulation_runner(mutated_worldpack_payload: Dict[str, Any]) -> Dict[str, Any]: + nonlocal revision_id + updated_draft = self.update_draft( + world_version_id, + mutated_worldpack_payload, + change_context={ + "source": "strategy_bundle_executor", + "label": f"执行策略包:{strategy_bundle.get('strategy_bundle_label') or strategy_bundle_id}", + "repair_loop_context": dict(selected_campaign.get("repair_loop_context") or {}), + }, + ) + revision_id = str((updated_draft.get("revision_history") or [{}])[-1].get("revision_id") or "") + return copy.deepcopy(self.run_simulation_for_world_version(world_version_id)) + + execution_receipt = execute_strategy_bundle_protocol( + worldpack_payload=worldpack_payload, + baseline_simulation_report=baseline_report, + campaign=selected_campaign, + strategy_bundle=strategy_bundle, + execution_mode="persistent_draft", + simulation_runner=_persistent_simulation_runner, + apply_step=self._apply_strategy_bundle_step, + build_result_attribution=self._build_strategy_bundle_result_attribution, + build_stop_decision=self._build_strategy_bundle_stop_decision, + prior_executions=previous_executions, + ) + execution_receipt["repair_loop_revision_id"] = revision_id + execution_receipt["repair_loop_context"] = dict(selected_campaign.get("repair_loop_context") or {}) + execution_receipt.pop("mutated_worldpack_payload", None) + execution_receipt.pop("rerun_report", None) + self._attach_strategy_bundle_execution( + world_version_id=world_version_id, + revision_id=revision_id, + execution_receipt=execution_receipt, + ) + return self.get_draft(world_version_id) + + def _latest_repair_loop_revision( + self, + revisions: List[Dict[str, Any]], + ) -> tuple[Optional[int], Optional[Dict[str, Any]]]: + for index in range(len(revisions) - 1, -1, -1): + revision = dict(revisions[index] or {}) + if dict(revision.get("repair_loop_context") or {}): + return index, revision + return None, None + + def _repair_loop_history(self, revisions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + history = [] + for revision in reversed(revisions): + context = dict(revision.get("repair_loop_context") or {}) + if not context: + continue + history.append( + { + "revision_id": revision.get("revision_id"), + "created_at": revision.get("created_at"), + "source": revision.get("source"), + "label": revision.get("label"), + "summary": revision.get("summary"), + "repair_loop_context": context, + "repair_loop_outcome": dict(revision.get("repair_loop_outcome") or {}), + } + ) + return history[:5] + + def _build_repair_loop_outcome( + self, + revisions: List[Dict[str, Any]], + *, + current_issue_groups: List[Dict[str, Any]], + current_chapter_heatmap: List[Dict[str, Any]], + ) -> Dict[str, Any]: + active_index, active_revision = self._latest_repair_loop_revision(revisions) + if active_revision is None: + return {} + repair_loop_context = dict(active_revision.get("repair_loop_context") or {}) + issue_code = str(repair_loop_context.get("issue_code") or "").strip() + if not issue_code: + return {} + + baseline_snapshot = dict(active_revision.get("simulation_snapshot") or {}) + if not baseline_snapshot: + for revision in reversed(revisions[:active_index]): + snapshot = dict(revision.get("simulation_snapshot") or {}) + if snapshot: + baseline_snapshot = snapshot + break + if not baseline_snapshot: + return {} + + baseline_issue_chapters = [ + dict(item) + for item in list(baseline_snapshot.get("chapter_snapshots") or []) + if issue_code in list(item.get("issue_codes") or []) + ] + current_issue_chapters = [ + dict(item) + for item in current_chapter_heatmap + if issue_code in list(item.get("issue_codes") or []) + ] + current_issue_group = next( + (dict(item) for item in current_issue_groups if str(item.get("issue_code") or "") == issue_code), + {}, + ) + targeted_indices = [ + int(item) + for item in ( + repair_loop_context.get("targeted_chapter_indices") + or [repair_loop_context.get("chapter_index")] + ) + if int(item or 0) > 0 + ] + baseline_targeted = [ + item for item in baseline_issue_chapters if int(item.get("chapter_index", 0) or 0) in targeted_indices + ] + current_targeted = [ + item for item in current_issue_chapters if int(item.get("chapter_index", 0) or 0) in targeted_indices + ] + baseline_worst_decision = max( + (str(item.get("decision") or "pass") for item in baseline_issue_chapters), + key=self._decision_severity, + default="pass", + ) + current_worst_decision = max( + (str(item.get("decision") or "pass") for item in current_issue_chapters), + key=self._decision_severity, + default="pass", + ) + baseline_issue_map = { + int(item.get("chapter_index", 0) or 0): dict(item) + for item in baseline_issue_chapters + if int(item.get("chapter_index", 0) or 0) > 0 + } + current_issue_map = { + int(item.get("chapter_index", 0) or 0): dict(item) + for item in current_issue_chapters + if int(item.get("chapter_index", 0) or 0) > 0 + } + resolved_indices = sorted(set(baseline_issue_map) - set(current_issue_map)) + remaining_indices = sorted(current_issue_map) + baseline_issue_count = len(baseline_issue_chapters) + current_issue_count = len(current_issue_chapters) + count_delta = int(current_issue_count - baseline_issue_count) + baseline_worst_score = self._decision_severity(baseline_worst_decision) + current_worst_score = self._decision_severity(current_worst_decision) + baseline_window_worst_decision = max( + (str(item.get("decision") or "pass") for item in baseline_targeted), + key=self._decision_severity, + default=baseline_worst_decision, + ) + current_window_worst_decision = max( + (str(item.get("decision") or "pass") for item in current_targeted), + key=self._decision_severity, + default=current_worst_decision, + ) + baseline_window_worst_score = self._decision_severity(baseline_window_worst_decision) + current_window_worst_score = self._decision_severity(current_window_worst_decision) + if baseline_issue_count > 0 and current_issue_count == 0: + severity_trend = "resolved" + elif current_issue_count < baseline_issue_count or current_worst_score < baseline_worst_score: + severity_trend = "improved" + elif current_issue_count > baseline_issue_count or current_worst_score > baseline_worst_score: + severity_trend = "regressed" + else: + severity_trend = "flat" + baseline_window_issue_count = len(baseline_targeted) + current_window_issue_count = len(current_targeted) + ready_for_validation = bool( + current_window_issue_count < baseline_window_issue_count + or current_window_worst_score < baseline_window_worst_score + or ( + baseline_window_issue_count == 0 + and ( + current_issue_count < baseline_issue_count + or current_worst_score < baseline_worst_score + ) + ) + ) + return { + "available": True, + "repair_loop_revision_id": active_revision.get("revision_id"), + "repair_loop_created_at": active_revision.get("created_at"), + "issue_code": issue_code, + "issue_label": repair_loop_context.get("issue_label") or ISSUE_TAXONOMY.get(issue_code, {}).get("label", issue_code), + "asset_type": repair_loop_context.get("asset_type", ""), + "asset_label": repair_loop_context.get("asset_label", ""), + "target_label": repair_loop_context.get("target_label", ""), + "validation_panel": repair_loop_context.get("validation_panel", ""), + "validation_panel_label": repair_loop_context.get("validation_panel_label", ""), + "validation_reason": repair_loop_context.get("validation_reason", ""), + "character_id": repair_loop_context.get("character_id", ""), + "scene_id": repair_loop_context.get("scene_id", ""), + "scene_function": repair_loop_context.get("scene_function", ""), + "chapter_task_id": repair_loop_context.get("chapter_task_id", ""), + "arc_id": repair_loop_context.get("arc_id", ""), + "volume_id": repair_loop_context.get("volume_id", ""), + "chapter_index": repair_loop_context.get("chapter_index"), + "chapter_title": repair_loop_context.get("chapter_title", ""), + "targeted_chapter_indices": targeted_indices, + "window_label": repair_loop_context.get("window_label", ""), + "window_breach_kind": repair_loop_context.get("window_breach_kind", ""), + "contract_failed_checks": list(repair_loop_context.get("contract_failed_checks", []) or []), + "baseline_issue_count": baseline_issue_count, + "current_issue_count": current_issue_count, + "count_delta": count_delta, + "baseline_targeted_issue_count": len(baseline_targeted), + "current_targeted_issue_count": len(current_targeted), + "baseline_window_issue_count": baseline_window_issue_count, + "current_window_issue_count": current_window_issue_count, + "baseline_worst_decision": baseline_worst_decision, + "current_worst_decision": current_worst_decision, + "baseline_window_worst_decision": baseline_window_worst_decision, + "current_window_worst_decision": current_window_worst_decision, + "severity_trend": severity_trend, + "resolved_chapters": [ + { + "chapter_index": chapter_index, + "chapter_title": baseline_issue_map[chapter_index].get("chapter_title", ""), + } + for chapter_index in resolved_indices + ], + "remaining_chapters": [ + { + "chapter_index": chapter_index, + "chapter_title": current_issue_map[chapter_index].get("chapter_title", ""), + } + for chapter_index in remaining_indices + ], + "resolved_window_chapters": [ + { + "chapter_index": int(item.get("chapter_index", 0) or 0), + "chapter_title": item.get("chapter_title", ""), + } + for item in baseline_targeted + if int(item.get("chapter_index", 0) or 0) not in {int(chapter.get("chapter_index", 0) or 0) for chapter in current_targeted} + ], + "remaining_window_chapters": [ + { + "chapter_index": int(item.get("chapter_index", 0) or 0), + "chapter_title": item.get("chapter_title", ""), + } + for item in current_targeted + ], + "ready_for_validation": ready_for_validation, + "fix_hint": ISSUE_TAXONOMY.get(issue_code, {}).get("fix_hint", ""), + "group_chapter_count": int(current_issue_group.get("chapter_count", current_issue_count) or current_issue_count), + "group_primary_asset_type": current_issue_group.get("primary_asset_type", ""), + } + + def _prepare_interactive_scenarios( + self, + version: WorldVersion, + interactive_scenarios: Optional[List[Dict[str, Any]]], + *, + max_chapters: int, + ) -> tuple[List[Dict[str, Any]], int]: + previous_completed = int((version.simulation_report_json or {}).get("completed_chapters", 0) or 0) + default_trigger_chapter = max(1, previous_completed + 1) + prepared: List[Dict[str, Any]] = [] + effective_budget = max(1, int(max_chapters)) + for index, item in enumerate(interactive_scenarios or [], start=1): + scenario = dict(item or {}) + if not scenario: + continue + directive = dict(scenario.get("steering_directive") or {}) + scenario_kind = str( + scenario.get("scenario_kind") + or directive.get("steering_type") + or ("memory_steer" if directive.get("memory_patch_note") else "mild_steer") + ) + trigger_chapter = scenario.get("trigger_chapter") + resolved_trigger = ( + max(1, int(trigger_chapter)) + if trigger_chapter not in (None, "") + else default_trigger_chapter + ) + prepared_scenario = { + "scenario_id": str(scenario.get("scenario_id") or f"author_steering_{index}"), + "scenario_kind": scenario_kind, + "label": str( + scenario.get("label") + or directive.get("summary") + or directive.get("current_user_intent") + or scenario_kind + ), + "trigger_chapter": resolved_trigger, + "steering_directive": directive, + } + prepared.append(prepared_scenario) + effective_budget = max(effective_budget, resolved_trigger) + return prepared, effective_budget + + def _build_creative_cockpit(self, worldpack_payload: Dict[str, Any], simulation_report: Dict[str, Any]) -> Dict[str, Any]: + if not simulation_report: + return {"available": False} + + final_state = dict(simulation_report.get("final_state_snapshot") or {}) + character_lookup = self._character_label_lookup(worldpack_payload, final_state) + scene_blueprints = [dict(item) for item in worldpack_payload.get("scene_blueprints", []) or []] + scene_by_function: Dict[str, Dict[str, Any]] = {} + for scene in scene_blueprints: + scene_function = str(scene.get("scene_function") or "") + if scene_function and scene_function not in scene_by_function: + scene_by_function[scene_function] = scene + role_to_character_ids: Dict[str, List[str]] = {} + for character in worldpack_payload.get("characters", []) or []: + role = str((dict(character or {})).get("role") or "").strip() + character_id = str((dict(character or {})).get("character_id") or "").strip() + if role and character_id: + role_to_character_ids.setdefault(role, []).append(character_id) + relationship_graph = [dict(item) for item in final_state.get("relationship_graph", [])] + metric_labels = { + "attachment": "牵引", + "resentment": "怨气", + "shame": "羞耻", + "obligation": "亏欠", + "projection": "投射", + "possession": "占有", + "gratitude": "感激", + "fear": "恐惧", + } + conflict_metrics = ("resentment", "shame", "projection", "possession", "fear") + ranked_edges: List[Dict[str, Any]] = [] + for index, edge in enumerate(relationship_graph, start=1): + metrics = { + metric: round(float(edge.get(metric, 0.0) or 0.0), 3) + for metric in metric_labels + } + dominant_metric = max(metrics.items(), key=lambda item: item[1])[0] + debt_entries = [dict(item) for item in edge.get("debts", [])] + debt_total = round(sum(float(item.get("magnitude", 0.0) or 0.0) for item in debt_entries), 3) + notes_preview = [str(note).strip() for note in edge.get("notes", []) if str(note).strip()][:2] + intensity = round(sum(metrics.values()) / float(max(1, len(metrics))), 3) + conflict = round( + sum(metrics.get(metric, 0.0) for metric in conflict_metrics) / float(len(conflict_metrics)), + 3, + ) + score = round(max(intensity, conflict) + min(1.0, debt_total) * 0.2, 3) + source_id = str(edge.get("source") or "") + target_id = str(edge.get("target") or "") + ranked_edges.append( + { + "edge_id": f"edge_{index}", + "source": source_id, + "target": target_id, + "source_label": character_lookup.get(source_id, {}).get("label", source_id), + "target_label": character_lookup.get(target_id, {}).get("label", target_id), + "dominant_metric": dominant_metric, + "dominant_metric_label": metric_labels.get(dominant_metric, dominant_metric), + "dominant_metric_value": metrics.get(dominant_metric, 0.0), + "intensity": intensity, + "conflict": conflict, + "debt_count": len(debt_entries), + "debt_total": debt_total, + "note_count": len([note for note in edge.get("notes", []) if str(note).strip()]), + "notes_preview": notes_preview, + "score": score, + } + ) + ranked_edges = sorted(ranked_edges, key=lambda item: (-float(item["score"]), item["edge_id"])) + + referenced_character_ids = { + character_id + for item in ranked_edges + for character_id in (item.get("source"), item.get("target")) + if str(character_id) + } + referenced_character_ids.update(str(character.get("character_id") or "") for character in worldpack_payload.get("characters", []) or []) + nodes = [ + { + "character_id": character_id, + "label": character_lookup.get(character_id, {}).get("label", character_id), + "role": character_lookup.get(character_id, {}).get("role", ""), + } + for character_id in sorted(referenced_character_ids) + if character_id + ] + + checkpoints = [dict(item) for item in simulation_report.get("steering_checkpoints", [])] + replan_history = [dict(item) for item in simulation_report.get("replan_history", [])] + memory_patch_summary = dict(simulation_report.get("memory_patch_summary") or {}) + chapter_trace = [dict(item) for item in simulation_report.get("chapter_trace", [])] + chapter_trace_by_index: Dict[int, Dict[str, Any]] = {} + for item in chapter_trace: + execution = dict(item.get("chapter_task_execution_summary") or {}) + chapter_index = int( + execution.get("series_chapter_index", 0) + or str(item.get("chapter_id") or "chapter_0").rsplit("_", 1)[-1] + or 0 + ) + if chapter_index > 0 and chapter_index not in chapter_trace_by_index: + chapter_trace_by_index[chapter_index] = item + steering_entries = [ + { + **( + lambda trace, task, matched_scene: { + "scene_id": str((matched_scene or {}).get("scene_id") or ""), + "scene_function": str(trace.get("scene_function") or (matched_scene or {}).get("scene_function") or ""), + "chapter_task_id": str(task.get("chapter_task_id") or task.get("task_id") or ""), + "arc_id": str(trace.get("arc_id") or item.get("affected_arc_id") or ""), + "volume_id": str(trace.get("volume_id") or ""), + } + )( + dict(chapter_trace_by_index.get(int(item.get("chapter_index", 0) or 0)) or {}), + dict((dict(chapter_trace_by_index.get(int(item.get("chapter_index", 0) or 0)) or {})).get("chapter_task") or {}), + scene_by_function.get(str((dict(chapter_trace_by_index.get(int(item.get("chapter_index", 0) or 0)) or {})).get("scene_function") or "")), + ), + "entry_type": "checkpoint", + "chapter_index": int(item.get("chapter_index", 0) or 0), + "title": str(item.get("summary") or item.get("scenario_kind") or "Steering"), + "summary": str(item.get("summary") or ""), + "status": "checkpoint", + "scenario_kind": str(item.get("scenario_kind") or ""), + "impacted_character_ids": [str(character_id) for character_id in item.get("impacted_character_ids", []) if str(character_id)], + "impacted_characters": [ + character_lookup.get(str(character_id), {}).get("label", str(character_id)) + for character_id in item.get("impacted_character_ids", []) + if str(character_id) + ], + } + for item in checkpoints + ] + [ + { + "entry_type": "replan", + "chapter_index": int(item.get("chapter_index", 0) or 0), + "title": f"{'强调整' if str(item.get('mode') or '') == 'strong' else '软调整'} Replan", + "summary": str(item.get("reason") or ""), + "status": str(item.get("mode") or "soft"), + "scenario_kind": str(item.get("reason") or ""), + "impacted_character_ids": [], + "impacted_characters": [], + "scene_id": "", + "scene_function": "", + "chapter_task_id": "", + "arc_id": str(item.get("arc_id") or ""), + "volume_id": str(item.get("volume_id") or ""), + } + for item in replan_history + ] + steering_entries = sorted( + steering_entries, + key=lambda item: (int(item.get("chapter_index", 0) or 0), 0 if item.get("entry_type") == "checkpoint" else 1), + ) + + chapter_breakdown = list( + (simulation_report.get("simulation_drilldown") or {}).get("chapter_breakdown") + or self._build_simulation_drilldown(simulation_report).get("chapter_breakdown", []) + ) + chapter_heatmap = [] + for item in chapter_breakdown: + issue_codes = list(item.get("issue_codes") or []) + decision = str(item.get("decision") or "rewrite") + severity = "critical" if decision == "block" else ("watch" if decision == "rewrite" else "stable") + trace = dict(chapter_trace_by_index.get(int(item.get("chapter_index", 0) or 0)) or {}) + chapter_task = dict(trace.get("chapter_task") or {}) + matched_scene = scene_by_function.get(str(trace.get("scene_function") or item.get("scene_function") or "")) + related_character_ids = self._resolve_related_character_ids( + matched_scene=dict(matched_scene or {}), + role_to_character_ids=role_to_character_ids, + character_lookup=character_lookup, + ) + chapter_heatmap.append( + { + "chapter_index": int(item.get("chapter_index", 0) or 0), + "chapter_title": str(item.get("chapter_title") or item.get("chapter_id") or ""), + "decision": decision, + "severity": severity, + "overall_score": round(float(item.get("overall_score", 0.0) or 0.0), 3), + "issue_count": len(issue_codes), + "issue_codes": issue_codes, + "dominant_issue": issue_codes[0] if issue_codes else "", + "scene_function": str(item.get("scene_function") or ""), + "scene_id": str((matched_scene or {}).get("scene_id") or ""), + "chapter_task_id": str(chapter_task.get("chapter_task_id") or chapter_task.get("task_id") or ""), + "arc_id": str(trace.get("arc_id") or ""), + "volume_id": str(trace.get("volume_id") or ""), + "related_character_ids": related_character_ids, + "related_characters": [ + character_lookup.get(character_id, {}).get("label", character_id) + for character_id in related_character_ids + ], + } + ) + volume_plans = sorted( + [dict(item) for item in (simulation_report.get("longform_plan_snapshot") or {}).get("volume_plans", [])], + key=lambda item: int(item.get("order", 0) or 0), + ) + arc_plans = sorted( + [dict(item) for item in (simulation_report.get("longform_plan_snapshot") or {}).get("arc_plans", [])], + key=lambda item: (str(item.get("volume_id") or ""), int(item.get("order", 0) or 0)), + ) + volume_snapshots = [dict(item) for item in final_state.get("volume_memory_snapshots", [])] + series_snapshots = [dict(item) for item in final_state.get("series_memory_snapshots", [])] + series_ending_checkpoint = dict(final_state.get("series_ending_checkpoint") or {}) + replan_stability_metrics = dict(final_state.get("replan_stability_metrics") or {}) + + volume_progress = [] + for volume in volume_plans: + volume_id = str(volume.get("volume_id") or "") + volume_chapters = [item for item in chapter_trace if str(item.get("volume_id") or "") == volume_id] + snapshot = next((item for item in volume_snapshots if str(item.get("volume_id") or "") == volume_id), {}) + status = "completed" if snapshot else ("active" if series_ending_checkpoint.get("current_volume_id") == volume_id else "planned") + volume_arc = next((arc for arc in arc_plans if str(arc.get("volume_id") or "") == volume_id), {}) + volume_progress.append( + { + "volume_id": volume_id, + "title": str(volume.get("title") or volume_id), + "first_arc_id": str((volume_arc or {}).get("arc_id") or ""), + "target_chapters": int(volume.get("target_chapters", 0) or 0), + "simulated_chapter_count": len(volume_chapters), + "first_simulation_chapter": ( + min(int(dict(item.get("chapter_task_execution_summary") or {}).get("series_chapter_index", 0) or 0) for item in volume_chapters) + if volume_chapters + else None + ), + "last_simulation_chapter": ( + max(int(dict(item.get("chapter_task_execution_summary") or {}).get("series_chapter_index", 0) or 0) for item in volume_chapters) + if volume_chapters + else None + ), + "memory_snapshot_count": 1 if snapshot else 0, + "status": status, + } + ) + + arc_progress = [] + for arc in arc_plans: + arc_id = str(arc.get("arc_id") or "") + arc_chapters = [item for item in chapter_trace if str(item.get("arc_id") or "") == arc_id] + arc_progress.append( + { + "arc_id": arc_id, + "title": str(arc.get("title") or arc_id), + "volume_id": str(arc.get("volume_id") or ""), + "first_task_id": str(((arc.get("chapter_tasks") or [{}])[0] or {}).get("chapter_task_id") or ""), + "target_chapters": int(arc.get("target_chapters", 0) or 0), + "simulated_chapter_count": len(arc_chapters), + "status": "active" if series_ending_checkpoint.get("current_arc_id") == arc_id else ("completed" if arc_chapters else "planned"), + } + ) + + available = bool( + ranked_edges + or checkpoints + or replan_history + or chapter_heatmap + or volume_progress + or series_snapshots + or series_ending_checkpoint + ) + return { + "available": available, + "relationship_network": { + "available": bool(nodes or ranked_edges), + "node_count": len(nodes), + "edge_count": len(ranked_edges), + "nodes": nodes, + "edges": ranked_edges, + }, + "relationship_hotspots": { + "available": bool(ranked_edges), + "items": ranked_edges[:6], + }, + "steering_timeline": { + "available": bool(steering_entries or memory_patch_summary), + "checkpoint_count": len(checkpoints), + "replan_event_count": len(replan_history), + "memory_patch_summary": { + "pending_count": int(memory_patch_summary.get("pending_count", 0) or 0), + "adopted_count": int(memory_patch_summary.get("adopted_count", 0) or 0), + "characters_with_pending": [ + character_lookup.get(str(character_id), {}).get("label", str(character_id)) + for character_id in memory_patch_summary.get("characters_with_pending", []) + if str(character_id) + ], + "characters_with_adopted": [ + character_lookup.get(str(character_id), {}).get("label", str(character_id)) + for character_id in memory_patch_summary.get("characters_with_adopted", []) + if str(character_id) + ], + }, + "entries": steering_entries[-12:], + }, + "chapter_heatmap": { + "available": bool(chapter_heatmap), + "chapters": chapter_heatmap, + "decision_histogram": dict((simulation_report.get("simulation_drilldown") or {}).get("decision_histogram") or {}), + "issue_priority_groups": self._build_issue_priority_groups(chapter_heatmap), + }, + "story_structure_snapshot": { + "available": bool(volume_progress or arc_progress or series_snapshots or series_ending_checkpoint), + "volumes": volume_progress, + "arcs": arc_progress[:10], + "volume_snapshot_count": len(volume_snapshots), + "series_snapshot_count": len(series_snapshots), + "series_snapshots": series_snapshots[-3:], + "series_ending_checkpoint": series_ending_checkpoint, + "replan_stability_metrics": replan_stability_metrics, + }, + } + + def _build_simulation_diff_checkpoint(self, metadata: Dict[str, Any], simulation_report: Dict[str, Any]) -> Dict[str, Any]: + revisions = list(metadata.get("revision_history", [])) + if not revisions: + return {"available": False} + simulation_freshness = self._simulation_freshness(metadata, simulation_report) + latest_revision = revisions[-1] + latest_revision_id = latest_revision.get("revision_id") + last_simulated_revision_id = simulation_freshness.get("last_simulated_revision_id") + chapter_compare = self._build_before_after_chapter_compare(metadata) + pending_resimulation = bool( + latest_revision_id + and latest_revision_id != last_simulated_revision_id + ) + checkpoint_status = ( + "pending_resimulation" + if pending_resimulation + else ("ready" if chapter_compare.get("available") else "baseline_only") + ) + return { + "available": True, + "status": checkpoint_status, + "auto_resimulate_suggested": pending_resimulation, + "suggested_action": "simulate_draft" if pending_resimulation else ("review_compare" if chapter_compare.get("available") else "run_simulation"), + "latest_revision_id": latest_revision_id, + "latest_revision_label": latest_revision.get("label"), + "latest_revision_source": latest_revision.get("source"), + "latest_revision_summary": latest_revision.get("summary"), + "last_simulated_revision_id": last_simulated_revision_id, + "simulation_freshness": simulation_freshness, + "compare_available": bool(chapter_compare.get("available")), + "top_changed_chapter_count": len(chapter_compare.get("top_changed_chapters", [])), + "next_actions": ( + ["re_simulate_for_checkpoint", "review_compare_after_simulation"] + if pending_resimulation + else (["review_compare_after_simulation"] if chapter_compare.get("available") else ["run_simulation_checkpoint"]) + ), + } + + def _decorate_draft_payload(self, version: WorldVersion) -> dict[str, Any]: + metadata = dict((version.worldpack_json or {}).get("metadata", {})) + simulation_report = dict(version.simulation_report_json or {}) + simulation_report["_draft_metadata"] = metadata + content_quality_repair_workbench = self._build_content_quality_repair_workbench( + dict(version.worldpack_json or {}), + simulation_report, + ) + revision_compare = self._build_revision_compare(metadata, simulation_report) + before_after = self._build_before_after_chapter_compare(metadata) + capability = self._build_longform_capability_payload(worldpack_payload=dict(version.worldpack_json or {}), version=version) + runway_minimums = self._band_minimums("100") + structure_counts = dict(capability["structure_counts"] or {}) + latest_repair_loop_outcome = dict(simulation_report.get("latest_repair_loop_outcome") or {}) + revision_history = list(metadata.get("revision_history", [])) + latest_strategy_bundle_execution = ( + dict(simulation_report.get("latest_strategy_bundle_execution") or {}) + or self._latest_strategy_bundle_execution(revision_history) + ) + strategy_bundle_execution_history = ( + list(simulation_report.get("strategy_bundle_execution_history") or []) + or self._strategy_bundle_execution_history(revision_history) + ) + quick_brief_gaps = [] + for key, label in ( + ("character_count", "角色"), + ("scene_blueprint_count", "场景"), + ("location_count", "地点"), + ("scene_family_count", "scene family"), + ("distinct_role_pair_count", "role pairs"), + ): + threshold_key = { + "character_count": "min_characters", + "scene_blueprint_count": "min_scene_blueprints", + "location_count": "min_locations", + "scene_family_count": "min_scene_family_count", + "distinct_role_pair_count": "min_distinct_role_pairs", + }[key] + if int(structure_counts.get(key, 0) or 0) < int(runway_minimums.get(threshold_key, 0) or 0): + quick_brief_gaps.append(f"{label} {structure_counts.get(key, 0)}/{runway_minimums.get(threshold_key, 0)}") + quick_brief_runway_status = "ready" if not quick_brief_gaps else ("thin" if len(quick_brief_gaps) <= 2 else "insufficient") + default_campaign = dict(content_quality_repair_workbench.get("default_campaign") or {}) + return { + "world_version_id": version.world_version_id, + "world_id": version.world_id, + "status": version.status, + "worldpack": version.worldpack_json, + "entry_mode": capability["entry_mode"], + "requested_target_chapters": capability["requested_target_chapters"], + "requested_target_band": capability["requested_target_band"], + "supported_target_band": capability["supported_target_band"], + "claim_safe_band": capability["claim_safe_band"], + "requires_structured_longform": capability["requires_structured_longform"], + "longform_readiness": capability["longform_readiness"], + "longform_structure_counts": capability["structure_counts"], + "quick_brief_runway_summary": { + "status": quick_brief_runway_status, + "character_count": structure_counts.get("character_count", 0), + "scene_blueprint_count": structure_counts.get("scene_blueprint_count", 0), + "location_count": structure_counts.get("location_count", 0), + "scene_family_count": structure_counts.get("scene_family_count", 0), + "distinct_role_pair_count": structure_counts.get("distinct_role_pair_count", 0), + "gaps": quick_brief_gaps, + }, + "validation_report": version.validation_report_json, + "validation_drilldown": self._build_validation_drilldown(dict(version.validation_report_json or {})), + "simulation_report": version.simulation_report_json, + "revision_history": revision_history, + "latest_diff_summary": dict(metadata.get("latest_diff_summary", {})), + "diff_drilldown": { + **self._build_diff_drilldown(metadata), + "simulation_freshness": self._simulation_freshness(metadata, simulation_report), + }, + "simulation_drilldown": self._build_simulation_drilldown(simulation_report), + "longform_drilldown": self._build_longform_drilldown(simulation_report), + "promise_ledger_workbench": self._build_promise_ledger_workbench(simulation_report), + "promise_runway_summary": self._build_promise_runway_summary(simulation_report), + "promise_state_workbench": self._build_promise_state_workbench(metadata, simulation_report), + "series_volume_arc_promise_mapping": self._build_series_volume_arc_promise_mapping(simulation_report), + "chapter_task_simulation_linking": self._build_chapter_task_simulation_linking(simulation_report), + "continuity_diff_workbench": self._build_continuity_diff_workbench(metadata, simulation_report), + "character_fidelity_remediation_framework": self._build_character_fidelity_remediation_framework(simulation_report), + "continuity_override_workbench": self._build_continuity_override_workbench(metadata, simulation_report), + "simulation_diff_checkpoint": self._build_simulation_diff_checkpoint(metadata, simulation_report), + "steering_checkpoint_summary": self._build_steering_checkpoint_summary(simulation_report), + "replan_history": self._build_replan_history_summary(simulation_report), + "memory_patch_summary": self._build_memory_patch_summary_view(simulation_report), + "latest_repair_loop_outcome": latest_repair_loop_outcome, + "repair_loop_history": self._repair_loop_history(revision_history), + "latest_strategy_bundle_execution": latest_strategy_bundle_execution, + "strategy_bundle_execution_history": strategy_bundle_execution_history, + "hard_constraint_status": "blocked" if latest_repair_loop_outcome.get("issue_code") or default_campaign.get("issue_code") else "clear", + "blocking_dimension": str(latest_repair_loop_outcome.get("issue_code") or default_campaign.get("issue_code") or ""), + "window_breach_kind": str(latest_repair_loop_outcome.get("window_breach_kind") or default_campaign.get("breach_kind") or ""), + "ready_for_validation": ( + bool(latest_repair_loop_outcome.get("ready_for_validation", False)) + if latest_repair_loop_outcome + else not bool(default_campaign) + ), + "content_quality_repair_workbench": content_quality_repair_workbench, + "creative_cockpit": dict( + simulation_report.get("creative_cockpit") + or self._build_creative_cockpit(version.worldpack_json, simulation_report) + ), + "memory_compression_summary": dict( + simulation_report.get("longform_1000_summary") + or simulation_report.get("longform_500_summary") + or simulation_report.get("longform_250_summary") + or {} + ), + "volume_memory_snapshots": list((simulation_report.get("final_state_snapshot") or {}).get("volume_memory_snapshots", [])), + "series_memory_snapshots": list((simulation_report.get("final_state_snapshot") or {}).get("series_memory_snapshots", [])), + "replan_stability_metrics": dict((simulation_report.get("final_state_snapshot") or {}).get("replan_stability_metrics", {})), + "series_ending_checkpoint": dict((simulation_report.get("final_state_snapshot") or {}).get("series_ending_checkpoint", {})), + "longform_250_evidence": dict(simulation_report.get("longform_250_evidence") or {}), + "longform_500_evidence": dict(simulation_report.get("longform_500_evidence") or {}), + "longform_1000_evidence": dict(simulation_report.get("longform_1000_evidence") or {}), + "revision_compare": revision_compare, + "before_after_chapter_compare": before_after, + } + + def update_promise_state( + self, + world_version_id: str, + *, + promise_id: str, + editor_state: str, + notes: str = "", + chapter_index: Optional[int] = None, + chapter_task_id: Optional[str] = None, + arc_id: Optional[str] = None, + volume_id: Optional[str] = None, + ) -> Dict[str, Any]: + version = self.repository.get_world_version(world_version_id) + payload = copy.deepcopy(version.worldpack_json) + metadata = self._ensure_metadata(payload) + self._set_promise_state_override( + metadata, + promise_id=promise_id, + editor_state=editor_state, + notes=notes, + chapter_index=chapter_index, + chapter_task_id=chapter_task_id, + arc_id=arc_id, + volume_id=volume_id, + ) + return self.update_draft( + world_version_id, + payload, + change_context={"source": "promise_state_editor", "label": "保存 Promise 状态"}, + ) + + def update_continuity_override( + self, + world_version_id: str, + *, + chapter_index: int, + override_state: str, + notes: str = "", + issue_scope: Optional[List[str]] = None, + chapter_task_id: Optional[str] = None, + arc_id: Optional[str] = None, + volume_id: Optional[str] = None, + ) -> Dict[str, Any]: + version = self.repository.get_world_version(world_version_id) + payload = copy.deepcopy(version.worldpack_json) + metadata = self._ensure_metadata(payload) + self._set_continuity_override( + metadata, + chapter_index=chapter_index, + override_state=override_state, + notes=notes, + issue_scope=issue_scope, + chapter_task_id=chapter_task_id, + arc_id=arc_id, + volume_id=volume_id, + ) + return self.update_draft( + world_version_id, + payload, + change_context={"source": "continuity_override_editor", "label": "保存 Continuity Override"}, + ) + + def bulk_apply_task_continuity_override( + self, + world_version_id: str, + *, + chapter_indices: List[int], + override_state: str, + notes: str = "", + issue_scope: Optional[List[str]] = None, + chapter_task_id: Optional[str] = None, + arc_id: Optional[str] = None, + volume_id: Optional[str] = None, + ) -> Dict[str, Any]: + version = self.repository.get_world_version(world_version_id) + payload = copy.deepcopy(version.worldpack_json) + metadata = self._ensure_metadata(payload) + normalized_indices = sorted({int(item) for item in chapter_indices if int(item) > 0}) + for chapter_index in normalized_indices: + self._set_continuity_override( + metadata, + chapter_index=chapter_index, + override_state=override_state, + notes=notes, + issue_scope=issue_scope, + chapter_task_id=chapter_task_id, + arc_id=arc_id, + volume_id=volume_id, + ) + return self.update_draft( + world_version_id, + payload, + change_context={"source": "task_bulk_apply", "label": "批量应用 Task -> Simulation"}, + ) def _workflow_target_version(self, *, account_id: Optional[str], world_version_id: Optional[str]) -> Optional[WorldVersion]: if world_version_id: @@ -569,6 +4947,7 @@ def _workflow_blockers( validation_summary: Dict[str, Any], simulation_summary: Dict[str, Any], simulation_freshness: Dict[str, Any], + longform_readiness: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: blockers: List[Dict[str, Any]] = [] actions = dict(access.get("actions", {})) @@ -583,6 +4962,10 @@ def _workflow_blockers( } ) return blockers + for item in list(dict(longform_readiness or {}).get("blockers") or []): + blocker = dict(item or {}) + if blocker: + blockers.append(blocker) if validation_summary.get("available") and not validation_summary.get("ok", False): blockers.append( { @@ -626,11 +5009,21 @@ def _workflow_stage_and_action( validation_summary: Dict[str, Any], simulation_summary: Dict[str, Any], simulation_freshness: Dict[str, Any], - ) -> tuple[str, str]: + longform_readiness: Optional[Dict[str, Any]] = None, + ) -> tuple[str, str]: + readiness = dict(longform_readiness or {}) if version is None: return "brief", "create_from_brief" if version.status == "submitted": return "submitted", "wait_for_review" + if readiness.get("status") == "blocked": + if any(dict(item or {}).get("key") == "structured_longform_required" for item in list(readiness.get("blockers") or [])): + return "draft_created", "bootstrap_structured_longform" + return "draft_created", "focus_longform" + if readiness.get("status") == "needs_enrichment": + if str(readiness.get("band") or "100") == "100": + return "draft_created", "bootstrap_quick_brief_enrich" + return "draft_created", "bootstrap_structured_longform" if not validation_summary.get("available"): return "draft_created", "validate" if not validation_summary.get("ok"): @@ -699,8 +5092,10 @@ def _workflow_cta_actions( recommended_action: str, access: Dict[str, Any], version: Optional[WorldVersion], + longform_readiness: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: actions = dict(access.get("actions", {})) + readiness = dict(longform_readiness or {}) def _access_enabled(action_key: str) -> tuple[bool, Optional[str]]: action_access = actions.get(action_key, {}) @@ -712,6 +5107,28 @@ def _access_enabled(action_key: str) -> tuple[bool, Optional[str]]: ctas.append({"action_id": "create_from_brief", "label": "根据 Brief 生成 Draft", "primary": True, "enabled": allowed, "reason": reason}) save_allowed, save_reason = _access_enabled("save_draft") ctas.append({"action_id": "copy_current_world", "label": "从当前世界复制 Draft", "primary": False, "enabled": save_allowed, "reason": save_reason}) + elif recommended_action == "bootstrap_quick_brief_enrich": + ctas.append( + { + "action_id": "bootstrap_quick_brief_enrich", + "label": "补齐 100 章骨架", + "primary": True, + "enabled": True, + "reason": "当前 quick brief 的角色 / 场景 / 地点骨架还不足以安全承诺 100 章。", + } + ) + ctas.append({"action_id": "focus_longform", "label": "查看长篇规划", "primary": False, "enabled": True, "reason": None}) + elif recommended_action == "bootstrap_structured_longform": + ctas.append( + { + "action_id": "bootstrap_structured_longform", + "label": f"进入 {readiness.get('band') or '结构化'} 长篇蓝图", + "primary": True, + "enabled": True, + "reason": "当前目标长度已经超出 quick brief 可直接承诺的范围,需要先补齐结构化长篇骨架。", + } + ) + ctas.append({"action_id": "focus_longform", "label": "查看长篇规划", "primary": False, "enabled": True, "reason": None}) elif recommended_action == "validate": allowed, reason = _access_enabled("validate_draft") ctas.append({"action_id": "validate_draft", "label": "运行校验", "primary": True, "enabled": allowed, "reason": reason}) @@ -732,6 +5149,8 @@ def _access_enabled(action_key: str) -> tuple[bool, Optional[str]]: ctas.append({"action_id": "focus_version_history", "label": "查看版本轨迹", "primary": False, "enabled": True, "reason": None}) elif recommended_action == "wait_for_review": ctas.append({"action_id": "focus_version_history", "label": "查看审核状态", "primary": True, "enabled": True, "reason": None}) + elif recommended_action == "focus_longform": + ctas.append({"action_id": "focus_longform", "label": "打开长篇规划", "primary": True, "enabled": True, "reason": None}) if version is not None: ctas.append({"action_id": "focus_draft_detail", "label": "查看当前 Draft", "primary": False, "enabled": True, "reason": None}) return ctas @@ -892,6 +5311,11 @@ def _build_before_after_chapter_compare(self, metadata: Dict[str, Any]) -> Dict[ "after_revision_id": after.get("revision_id"), "chapter_compares": compares, "top_changed_chapters": compares[:5], + "chapter_compare_map": { + str(item.get("chapter_index")): dict(item) + for item in compares + if item.get("chapter_index") is not None + }, } def workflow_summary( @@ -916,6 +5340,34 @@ def workflow_summary( validation_summary = self._validation_summary(validation_report) simulation_summary = self._simulation_summary(simulation_report) simulation_freshness = self._simulation_freshness(metadata, simulation_report) + simulation_longform_drilldown = self._build_longform_drilldown(simulation_report) + longform_capability = self._build_longform_capability_payload( + worldpack_payload=dict(version.worldpack_json or {}) if version else {}, + version=version, + ) if version else { + "entry_mode": "quick_brief", + "requested_target_chapters": self._quick_brief_max_target_chapters(), + "requested_target_band": "100", + "supported_target_band": None, + "claim_safe_band": None, + "requires_structured_longform": False, + "structure_counts": {"character_count": 0, "scene_blueprint_count": 0, "location_count": 0}, + "longform_readiness": { + "band": "100", + "status": "blocked", + "blockers": [], + "recommended_actions": ["create_from_brief"], + "minimums": self._band_minimums("100"), + }, + } + if simulation_longform_drilldown.get("longform_structure_exhaustion"): + longform_capability["longform_readiness"] = { + **dict(longform_capability.get("longform_readiness") or {}), + "status": "blocked", + "blockers": list(dict(longform_capability.get("longform_readiness") or {}).get("blockers") or []) + + [dict(simulation_longform_drilldown.get("longform_structure_exhaustion") or {})], + "recommended_actions": list((simulation_longform_drilldown.get("longform_structure_exhaustion") or {}).get("recommended_actions") or []), + } collaboration_summary = self._collaboration_summary(version.world_version_id) if version else { "open_thread_count": 0, "blocking_thread_count": 0, @@ -933,6 +5385,7 @@ def workflow_summary( validation_summary=validation_summary, simulation_summary=simulation_summary, simulation_freshness=simulation_freshness, + longform_readiness=longform_capability["longform_readiness"], ) latest_approval_status = approval_summary.get("latest_status") if collaboration_summary.get("blocking_thread_count", 0) > 0: @@ -944,7 +5397,14 @@ def workflow_summary( elif latest_approval_status == "changes_requested": stage = "changes_requested" recommended_action = "revise" - elif latest_approval_status == "approved" and simulation_freshness.get("status") == "fresh" and stage == "ready_to_submit": + elif ( + latest_approval_status == "approved" + and validation_summary.get("ok") + and simulation_freshness.get("status") == "fresh" + and simulation_summary.get("block_rate", 0.0) <= 0.0 + and str(simulation_summary.get("latest_decision") or "").lower() not in {"block", "blocked", "failed"} + and stage in {"ready_to_submit", "simulated"} + ): stage = "approved_for_submit" recommended_action = "submit" blockers = self._workflow_blockers( @@ -953,13 +5413,34 @@ def workflow_summary( validation_summary=validation_summary, simulation_summary=simulation_summary, simulation_freshness=simulation_freshness, + longform_readiness=longform_capability["longform_readiness"], ) + if latest_approval_status == "approved" and stage == "approved_for_submit": + blockers = [item for item in blockers if item.get("key") != "simulation_requires_revision"] return { "account_id": resolved_account_id, "world_version_id": version.world_version_id if version else None, "world_id": version.world_id if version else None, "draft_title": (version.worldpack_json or {}).get("title") if version else None, "status": version.status if version else "no_draft", + "entry_mode": longform_capability["entry_mode"], + "requested_target_chapters": longform_capability["requested_target_chapters"], + "requested_target_band": longform_capability["requested_target_band"], + "supported_target_band": longform_capability["supported_target_band"], + "claim_safe_band": longform_capability["claim_safe_band"], + "requires_structured_longform": longform_capability["requires_structured_longform"], + "longform_readiness": longform_capability["longform_readiness"], + "longform_structure_counts": longform_capability["structure_counts"], + "quick_brief_runway_summary": ( + self.get_draft(version.world_version_id).get("quick_brief_runway_summary") + if version is not None + else {} + ), + "promise_runway_summary": ( + self._build_promise_runway_summary(simulation_report) + if simulation_report + else {"available": False} + ), "stage": stage, "recommended_action": recommended_action, "blockers": blockers, @@ -986,16 +5467,23 @@ def workflow_summary( "validation_summary": validation_summary, "simulation_summary": simulation_summary, "simulation_freshness": simulation_freshness, + "interactive_longform_signoff": dict((simulation_report.get("cross_pack_summary") or {}).get("interactive_longform_signoff") or {}), + "longform_250_interactive_signoff": dict((simulation_report.get("cross_pack_summary") or {}).get("longform_250_interactive_signoff") or {}), + "longform_500_interactive_signoff": dict((simulation_report.get("cross_pack_summary") or {}).get("longform_500_interactive_signoff") or {}), "cta_actions": self._workflow_cta_actions( recommended_action=recommended_action, access=access, version=version, + longform_readiness=longform_capability["longform_readiness"], ), } def save_draft(self, worldpack: dict[str, Any], *, change_context: Optional[Dict[str, Any]] = None) -> dict[str, Any]: pack = WorldPack.from_dict(worldpack) pack_payload = pack.to_dict() + if self._should_persist_longform_capability_metadata(pack_payload): + self._sync_longform_capability_metadata(pack_payload) + _ensure_character_asset_coverage(pack_payload) context = self._normalize_change_context(change_context, default_source="manual_update", default_label="创建 draft") self._append_revision( worldpack_payload=pack_payload, @@ -1008,22 +5496,17 @@ def save_draft(self, worldpack: dict[str, Any], *, change_context: Optional[Dict "summary_text": context["label"], }, ) - pack = WorldPack.from_dict(pack_payload) - report = validate_worldpack_payload(pack.to_dict()) - world_version_id = "%s@%s" % (pack.world_id, pack.version) + normalized_pack = WorldPack.from_dict(pack_payload) + report = validate_worldpack_payload(pack_payload) + world_version_id = "%s@%s" % (normalized_pack.world_id, normalized_pack.version) version = WorldVersion.from_worldpack( - worldpack=pack, + worldpack=normalized_pack, world_version_id=world_version_id, status="draft", validation_report_json=report, ) self.repository.save_world_version(version, publish=False) - return { - "world_version_id": world_version_id, - "world_id": pack.world_id, - "status": "draft", - "validation_report": report, - } + return self.get_draft(world_version_id) def get_brief_template(self) -> dict[str, Any]: template_path = self.base_dir / "examples" / "worldpacks" / "author_brief_template.yaml" @@ -1046,7 +5529,13 @@ def get_brief_template(self) -> dict[str, Any]: "paid_after": 3, "risk_rating": "PG-13", "author_id": "web_author", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, }, + "quick_brief_max_target_chapters": self._quick_brief_max_target_chapters(), + "structured_longform_bands": list(self._longform_capability_profiles().get("structured_longform_bands") or []), + "longform_capability_profiles": dict(self._longform_capability_profiles().get("bands") or {}), "genre_presets": [ {"id": "jade_court", "label": "权门伦理", "description": "家门、体面、师长压力与真心拉扯。"}, {"id": "urban_mystery", "label": "都市情感悬疑", "description": "旧巷、隐瞒、关系债与真相回潮。"}, @@ -1069,7 +5558,22 @@ def get_brief_template(self) -> dict[str, Any]: } def create_draft_from_brief(self, brief: dict[str, Any]) -> dict[str, Any]: - pack = WorldPack.from_dict(self._worldpack_from_brief(brief)) + worldpack_payload = self._worldpack_from_brief(brief) + metadata = self._ensure_metadata(worldpack_payload) + requested_target_chapters = max(24, int(brief.get("target_total_chapters") or 100)) + metadata["entry_mode"] = "quick_brief" + metadata["requested_target_chapters"] = requested_target_chapters + metadata["generated_from_brief"] = True + if requested_target_chapters <= self._quick_brief_max_target_chapters(): + self._apply_longform_asset_enrichment(worldpack_payload=worldpack_payload, target_band="100") + metadata["longform_program_stage"] = "L1_foundation_enriched" + metadata["quick_brief_enriched"] = True + metadata["quick_brief_enriched_band"] = "100" + else: + metadata["quick_brief_enriched"] = False + metadata["quick_brief_enriched_band"] = None + self._sync_longform_capability_metadata(worldpack_payload) + pack = WorldPack.from_dict(worldpack_payload) return self.save_draft( pack.to_dict(), change_context={"source": "brief_create", "label": "从 brief 生成 draft"}, @@ -1094,6 +5598,9 @@ def _worldpack_from_brief(self, brief: dict[str, Any]) -> dict[str, Any]: trial_chapters = int(brief.get("trial_chapters") or 2) paid_after = int(brief.get("paid_after") or 3) author_id = str(brief.get("author_id") or "web_author") + target_total_chapters = max(12, int(brief.get("target_total_chapters") or 100)) + target_total_volumes = max(1, int(brief.get("target_total_volumes") or 5)) + target_word_count = max(20000, int(brief.get("target_word_count") or (target_total_chapters * 2000))) payload["world_id"] = world_id payload["title"] = world_title @@ -1131,9 +5638,31 @@ def _worldpack_from_brief(self, brief: dict[str, Any]) -> dict[str, Any]: payload["emotion_action_policies"] = _build_action_policies_for_preset(preset_id) payload["sensory_grounding_policies"] = _build_sensory_policies_for_preset(preset_id, locations) payload["scene_realization_contracts"] = _build_scene_realization_for_preset(preset_id) + longform_structure = _build_longform_structure( + world_id=world_id, + world_title=world_title, + life_theme=life_theme, + target_total_chapters=target_total_chapters, + target_total_volumes=target_total_volumes, + target_word_count=target_word_count, + ) + payload["series_plan"] = longform_structure["series_plan"] + payload["volume_plans"] = longform_structure["volume_plans"] + payload["arc_plans"] = longform_structure["arc_plans"] + payload["chapter_budget_policy"] = longform_structure["chapter_budget_policy"] + payload["series_storyline_contract"] = _build_storyline_contract_from_brief( + world_title=world_title, + core_premise=core_premise, + life_theme=life_theme, + volume_plans=payload["volume_plans"], + ) + payload["character_memory_profiles"] = _build_character_memory_profiles_from_characters(payload["characters"]) + payload["steering_guardrails"] = _default_steering_guardrails() + payload["memory_compression_policy"] = _default_memory_compression_policy(target_total_volumes) payload["metadata"] = { "author_brief": dict(brief), "generated_from_brief": True, + "longform_program_stage": "L1_foundation", } payload["narrative_style_pack"] = { "style_pack_id": "%s_style" % preset_id, @@ -1163,6 +5692,10 @@ def update_draft(self, world_version_id: str, worldpack: dict[str, Any], *, chan previous_worldpack = copy.deepcopy(version.worldpack_json) pack = WorldPack.from_dict(worldpack) next_payload = pack.to_dict() + if self._should_persist_longform_capability_metadata(next_payload): + self._sync_longform_capability_metadata(next_payload, version=version) + _ensure_character_asset_coverage(next_payload) + normalized_pack = WorldPack.from_dict(next_payload) context = self._normalize_change_context(change_context, default_source="manual_update", default_label="手动更新 draft") diff_summary = self._diff_sections(previous_worldpack, next_payload) self._append_revision( @@ -1171,12 +5704,81 @@ def update_draft(self, world_version_id: str, worldpack: dict[str, Any], *, chan diff_summary=diff_summary, ) version.worldpack_json = next_payload - version.manifest_json = pack.manifest.to_dict() - version.validation_report_json = validate_worldpack_payload(pack.to_dict()) + version.manifest_json = normalized_pack.manifest.to_dict() + version.validation_report_json = validate_worldpack_payload(next_payload) version.status = "draft" self.repository.save_world_version(version, publish=False) return self.get_draft(world_version_id) + def bootstrap_longform_workbench( + self, + world_version_id: str, + *, + mode: str = "structured_longform", + target_band: Optional[str] = None, + ) -> dict[str, Any]: + version = self.repository.get_world_version(world_version_id) + payload = copy.deepcopy(version.worldpack_json) + normalized_mode = str(mode or "structured_longform").strip() or "structured_longform" + if normalized_mode not in {"quick_brief_enrich", "structured_longform"}: + raise ValueError("invalid_longform_bootstrap_mode") + resolved_target_band = str(target_band or "").strip() or self._target_band_for_chapters( + int(((payload.get("metadata") or {}).get("author_brief") or {}).get("target_total_chapters") or ((payload.get("series_plan") or {}).get("total_chapter_target") or 100)) + ) + if resolved_target_band not in LONGFORM_CAPABILITY_BAND_ORDER: + raise ValueError("invalid_longform_target_band") + if normalized_mode == "quick_brief_enrich": + resolved_target_band = "100" + if payload.get("series_plan") and payload.get("volume_plans") and payload.get("arc_plans"): + payload.setdefault( + "series_storyline_contract", + _build_storyline_contract_from_brief( + world_title=str(payload.get("title") or version.world_id), + core_premise=str((payload.get("world_bible") or {}).get("premise") or payload.get("title") or version.world_id), + life_theme=str((payload.get("metadata") or {}).get("author_brief", {}).get("life_theme") or ""), + volume_plans=list(payload.get("volume_plans") or []), + ), + ) + else: + structure = _bootstrap_longform_structure_payload( + worldpack_payload=payload, + runtime_world_title=str(payload.get("title") or version.world_id), + ) + payload["series_plan"] = dict(structure["series_plan"]) + payload["volume_plans"] = [dict(item) for item in structure["volume_plans"]] + payload["arc_plans"] = [dict(item) for item in structure["arc_plans"]] + payload["chapter_budget_policy"] = dict(structure["chapter_budget_policy"]) + structure = _bootstrap_longform_structure_payload( + worldpack_payload=payload, + runtime_world_title=str(payload.get("title") or version.world_id), + ) + payload["series_storyline_contract"] = _build_storyline_contract_from_brief( + world_title=str(payload.get("title") or version.world_id), + core_premise=str((payload.get("world_bible") or {}).get("premise") or payload.get("title") or version.world_id), + life_theme=str((payload.get("metadata") or {}).get("author_brief", {}).get("life_theme") or ""), + volume_plans=list(payload.get("volume_plans") or []), + ) + payload["steering_guardrails"] = _default_steering_guardrails() + payload["memory_compression_policy"] = _default_memory_compression_policy(len(payload.get("volume_plans") or [])) + self._apply_longform_asset_enrichment(worldpack_payload=payload, target_band=resolved_target_band) + metadata = self._ensure_metadata(payload) + metadata["entry_mode"] = normalized_mode if normalized_mode == "quick_brief_enrich" else "structured_longform" + metadata["longform_program_stage"] = "L2_workbench" if normalized_mode == "structured_longform" else "L1_foundation_enriched" + metadata["longform_workbench_bootstrapped"] = True + metadata["longform_workbench_plan_source"] = structure.get("plan_source", "workbench_bootstrap") + metadata["structured_longform_target_band"] = resolved_target_band if normalized_mode == "structured_longform" else metadata.get("structured_longform_target_band") + metadata["quick_brief_enriched"] = normalized_mode == "quick_brief_enrich" + metadata["quick_brief_enriched_band"] = resolved_target_band if normalized_mode == "quick_brief_enrich" else metadata.get("quick_brief_enriched_band") + self._sync_longform_capability_metadata(payload) + return self.update_draft( + world_version_id, + payload, + change_context={ + "source": "longform_workbench_bootstrap" if normalized_mode == "structured_longform" else "quick_brief_enrich", + "label": "生成 Longform Workbench 规划" if normalized_mode == "structured_longform" else "补齐 100 章 quick brief 长线骨架", + }, + ) + def _select_candidate_world_version_id(self, world_id: str) -> str: versions = self.repository.list_world_versions(world_id=world_id) candidate = next((item for item in versions if item["status"] == "draft"), None) or (versions[0] if versions else None) @@ -1190,23 +5792,55 @@ def _baseline(self) -> Dict[str, Any] | None: return json.loads(baseline_path.read_text(encoding="utf-8")) return None - def _build_cross_pack_summary(self, world_id: str, world_version_id: str) -> Dict[str, Any]: + def _build_cross_pack_summary(self, world_id: str, world_version_id: str, *, benchmark_mode: Optional[str] = None, max_chapters: int = 6) -> Dict[str, Any]: + def _simulation_runner( + benchmark_world_id: str, + benchmark_world_version_id: str, + interactive_scenarios: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + return self.run_simulation_for_world_version( + benchmark_world_version_id, + include_cross_pack=False, + max_chapters=max_chapters, + interactive_scenarios=interactive_scenarios, + ) + summary = run_benchmark( repository=self.repository, golden_dir=self.base_dir / "tests" / "golden_routes", worldpack="all", baseline=self._baseline(), world_version_overrides={world_id: world_version_id}, - simulation_runner=lambda benchmark_world_id, benchmark_world_version_id: self.run_simulation_for_world_version( - benchmark_world_version_id, - include_cross_pack=False, - ), + benchmark_mode=benchmark_mode, + max_chapters=max_chapters, + simulation_runner=_simulation_runner, ) return { "cross_pack_pass_rate": summary.get("cross_pack_pass_rate", 0.0), "top_failing_packs": summary.get("top_failing_packs", []), "delta_summary": summary.get("delta_summary", {}), "worlds": summary.get("worlds", []), + "weakest_pack_polish_program": summary.get("weakest_pack_polish_program", {}), + "longform_l1_signoff": summary.get("longform_l1_signoff", {}), + "interactive_longform_signoff": summary.get("interactive_longform_signoff", {}), + "longform_250_signoff": summary.get("longform_250_signoff", {}), + "longform_250_interactive_signoff": summary.get("longform_250_interactive_signoff", {}), + "longform_250_human_review_closeout": summary.get("longform_250_human_review_closeout", {}), + "longform_250_evidence": summary.get("longform_250_evidence", {}), + "longform_500_signoff": summary.get("longform_500_signoff", {}), + "longform_500_interactive_signoff": summary.get("longform_500_interactive_signoff", {}), + "longform_500_human_review_closeout": summary.get("longform_500_human_review_closeout", {}), + "longform_500_ending_signoff": summary.get("longform_500_ending_signoff", {}), + "longform_500_evidence": summary.get("longform_500_evidence", {}), + "longform_1000_readiness": summary.get("longform_1000_readiness", {}), + "longform_1000_interactive_signoff": summary.get("longform_1000_interactive_signoff", {}), + "longform_1000_human_review_closeout": summary.get("longform_1000_human_review_closeout", {}), + "longform_1000_feasibility": summary.get("longform_1000_feasibility", {}), + "longform_1000_evidence": summary.get("longform_1000_evidence", {}), + "character_fidelity_remediation_framework": summary.get("character_fidelity_remediation_framework", {}), + "review_sample_coverage_250": summary.get("review_sample_coverage_250", {}), + "review_sample_coverage_500": summary.get("review_sample_coverage_500", {}), + "review_sample_coverage_1000": summary.get("review_sample_coverage_1000", {}), } def run_simulation_for_world_version( @@ -1216,20 +5850,118 @@ def run_simulation_for_world_version( include_cross_pack: bool = True, max_chapters: int = 6, min_end_turn_override: int | None = None, + interactive_scenarios: Optional[List[Dict[str, Any]]] = None, + longform_setup_override: Optional[Dict[str, Any]] = None, + progress_callback: Optional[Any] = None, ) -> dict[str, Any]: version = self.repository.get_world_version(world_version_id) + prepared_interactive_scenarios, max_chapters = self._prepare_interactive_scenarios( + version, + interactive_scenarios, + max_chapters=max_chapters, + ) runtime = self.repository.get_runtime_bundle(world_version_id) state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.metadata["authoring_simulation_chapter_budget"] = int(max_chapters) if min_end_turn_override is not None: state.min_end_turn = max(int(min_end_turn_override), int(state.min_end_turn)) + if max_chapters >= 1000: + state.metadata["longform_diagnostics_mode"] = "longform_1000" + worldpack_payload = dict(version.worldpack_json or {}) + longform_structure = _resolve_longform_structure( + worldpack_payload=worldpack_payload, + runtime_world_title=runtime.world_record.world.title, + max_chapters=max_chapters, + ) + series_plan = dict(longform_structure.get("series_plan") or {}) + volume_plans = list(longform_structure.get("volume_plans") or []) + arc_plans = list(longform_structure.get("arc_plans") or []) + chapter_budget_policy = dict(longform_structure.get("chapter_budget_policy") or {}) + plan_source = str(longform_structure.get("plan_source") or "worldpack") + setup_override = dict(longform_setup_override or {}) + state.metadata["authoring_simulation_quality_mode"] = str( + setup_override.get("authoring_simulation_quality_mode") or "" + ) + resolved_memory_compression_policy = dict( + worldpack_payload.get("memory_compression_policy") + or _default_memory_compression_policy(len(volume_plans)) + ) + series_storyline_contract = { + **dict(worldpack_payload.get("series_storyline_contract") or {}), + **dict(setup_override.get("series_storyline_contract") or {}), + } + character_memory_profiles = { + **dict(worldpack_payload.get("character_memory_profiles") or {}), + **dict(setup_override.get("character_memory_profiles") or {}), + } + steering_guardrails = { + **_default_steering_guardrails(), + **dict(worldpack_payload.get("steering_guardrails") or {}), + **dict(setup_override.get("steering_guardrails") or {}), + } + configure_longform_runtime( + state, + series_plan=series_plan, + volume_plans=volume_plans, + arc_plans=arc_plans, + chapter_budget_policy=chapter_budget_policy, + memory_compression_policy=resolved_memory_compression_policy, + world=runtime.world_record.world, + ) + configure_interactive_longform_runtime( + state, + series_storyline_contract=series_storyline_contract, + character_memory_profiles=character_memory_profiles, + steering_guardrails=steering_guardrails, + ) completed_chapters = 0 leak_detected = False latest_title = None reports = [] chapter_trace = [] stop_reason = "chapter_budget_reached" + scenario_queue = sorted( + [dict(item) for item in prepared_interactive_scenarios], + key=lambda item: int(item.get("trigger_chapter", 0) or 0), + ) + has_interactive_scenarios = bool(prepared_interactive_scenarios) + steering_checkpoints: List[Dict[str, Any]] = [] + replan_history: List[Dict[str, Any]] = [] for _ in range(max_chapters): + next_chapter_index = int(state.chapter_index or 0) + 1 + if progress_callback is not None and ( + next_chapter_index == 1 or next_chapter_index % 25 == 0 or next_chapter_index == max_chapters + ): + try: + progress_callback( + "chapter_start", + chapter_index=next_chapter_index, + max_chapters=max_chapters, + ) + except Exception: + pass + while scenario_queue and int(scenario_queue[0].get("trigger_chapter", 0) or 0) == next_chapter_index: + scenario = scenario_queue.pop(0) + directive = dict(scenario.get("steering_directive") or {}) + directive.setdefault("steering_type", scenario.get("scenario_kind")) + directive.setdefault("summary", scenario.get("label") or scenario.get("scenario_kind") or "interactive_steer") + checkpoint = apply_steering_directive( + state, + directive, + world=runtime.world_record.world, + ) + if checkpoint.get("applied"): + steering_checkpoints.append( + { + "scenario_id": str(scenario.get("scenario_id") or f"scenario_{len(steering_checkpoints) + 1}"), + "scenario_kind": str(scenario.get("scenario_kind") or directive.get("steering_type") or "mild_steer"), + "chapter_index": next_chapter_index, + "summary": str(directive.get("summary") or ""), + "impacted_character_ids": list(checkpoint.get("entry", {}).get("impacted_character_ids", [])), + } + ) + replan_history.append(dict(state.replan_checkpoint or {})) candidate_provider = ( self.provider_routing.build_candidate_provider( runtime.event_atoms, @@ -1253,23 +5985,38 @@ def run_simulation_for_world_version( if self.provider_routing else TemplateRenderer() ) + diagnostics_light_debug = max_chapters >= 1000 started = perf_counter() result = plan_next_turn( state, world=runtime.world_record.world, candidate_provider=candidate_provider, renderer=active_renderer, - debug=True, + debug=not diagnostics_light_debug, ) runtime_latency_ms = round((perf_counter() - started) * 1000.0, 3) if result["status"] != "ok": stop_reason = str(result.get("status", "stopped")) + if progress_callback is not None: + try: + progress_callback( + "chapter_blocked", + chapter_index=next_chapter_index, + max_chapters=max_chapters, + stop_reason=stop_reason, + ) + except Exception: + pass break completed_chapters += 1 latest_title = result["reader_view"]["chapter_title"] leak_detected = leak_detected or ("event_id" in result["reader_view"]["body"] or "seed_id" in result["reader_view"]["body"]) state = NarrativeState.from_dict(result["updated_state"]) + lint_started = perf_counter() lint_report = lint_chapter_draft(result["reader_view"]["body"]) + lint_latency_ms = round((perf_counter() - lint_started) * 1000.0, 3) + chosen_candidate_summary = dict(result.get("chosen_candidate_summary") or {}) + evaluation_started = perf_counter() report = evaluate_chapter( chapter_id="simulation_%s_%s" % (world_version_id, completed_chapters), world_version_id=world_version_id, @@ -1279,18 +6026,38 @@ def run_simulation_for_world_version( dialogue_count=int(lint_report["dialogue_count"]), action_count=int(lint_report["action_count"]), detail_count=int(lint_report["detail_count"]), - character_fidelity_score=max( - [item["components"].get("character_fidelity", 0.0) for item in result["scored_candidates"]], - default=0.0, + character_fidelity_score=float( + dict(chosen_candidate_summary.get("components") or {}).get( + "character_fidelity", + max( + [item["components"].get("character_fidelity", 0.0) for item in result.get("scored_candidates", [])], + default=0.0, + ), + ) + or 0.0 ), state_after=state, ending_ready=bool(result["chapter_plan"]["ending_ready"]) if result.get("chapter_plan") else False, choices=result["reader_view"]["choices"], paywall_required=False, + coverage_context={ + "selected_event_ids": list((result.get("chapter_plan") or {}).get("selected_event_ids", [])), + "scene_beats": list(result.get("scene_beats") or []), + "chapter_task": dict((result.get("chapter_plan") or {}).get("chapter_task") or {}), + }, + ) + evaluation_latency_ms = round((perf_counter() - evaluation_started) * 1000.0, 3) + record_replan_debt( + state, + chapter_index=completed_chapters, + issue_codes=[issue.issue_code for issue in report.issues], ) reports.append(report) rendered_debug = dict((result.get("rendered_scene") or {}).get("debug") or {}) draft_metadata = dict(rendered_debug.get("draft_metadata") or {}) + render_timing_ms = dict(rendered_debug.get("timing_ms") or {}) + quality_pass_timing_ms = dict(draft_metadata.get("quality_pass_timing_ms") or {}) + evaluation_payload = report.to_dict() chapter_trace.append( { "chapter_id": "simulation_%s_%s" % (world_version_id, completed_chapters), @@ -1303,11 +6070,51 @@ def run_simulation_for_world_version( "choices_preview": list((result.get("reader_view") or {}).get("choices", []))[:3], "quality_pass_applied": bool(draft_metadata.get("quality_pass_applied", False)), "quality_pass_actions": list(draft_metadata.get("quality_pass_actions", [])), + "quality_pass_timing_ms": quality_pass_timing_ms, "critic_signal_count": len(result.get("critic_trace") or []), + "series_id": (result.get("updated_state") or {}).get("current_series_id"), + "volume_id": (result.get("updated_state") or {}).get("current_volume_id"), + "arc_id": (result.get("updated_state") or {}).get("current_arc_id"), + "chapter_task": dict((result.get("chapter_plan") or {}).get("chapter_task") or {}), + "chapter_task_execution_summary": dict((result.get("chapter_plan") or {}).get("chapter_task_execution_summary") or {}), + "open_promise_ids": [promise.promise_id for promise in state.open_promises], + "open_promise_count": len(state.open_promises), + "closed_promise_ids": list((state.metadata or {}).get("closed_promise_ids", [])), + "evaluation": { + "decision": evaluation_payload.get("decision", {}).get("decision"), + "overall_score": float((evaluation_payload.get("scores") or {}).get("overall_score", 0.0)), + "issue_codes": [issue.get("issue_code") for issue in evaluation_payload.get("issues", []) if issue.get("issue_code")], + }, "candidate_backend_routing": dict((result.get("candidate_batch") or {}).get("debug", {}).get("backend_routing") or {}), "renderer_backend_routing": dict((result.get("rendered_scene") or {}).get("debug", {}).get("backend_routing") or {}), + "renderer_attempt_count": int(rendered_debug.get("renderer_attempt_count") or 0), + "renderer_fallback_reason": rendered_debug.get("renderer_fallback_reason"), + "llm_payload_gate": dict(rendered_debug.get("llm_payload_gate") or {}), + "llm_length_gate": dict(rendered_debug.get("llm_length_gate") or {}), + "runtime_latency_ms": runtime_latency_ms, + "lint_latency_ms": lint_latency_ms, + "evaluation_latency_ms": evaluation_latency_ms, + "render_timing_ms": render_timing_ms, + "planner_trace_summary": dict(result.get("planner_trace_summary") or {}), + "chosen_candidate_summary": chosen_candidate_summary, } ) + if progress_callback is not None and ( + completed_chapters == 1 or completed_chapters % 25 == 0 or completed_chapters == max_chapters + ): + try: + progress_callback( + "chapter_complete", + chapter_index=completed_chapters, + max_chapters=max_chapters, + runtime_latency_ms=runtime_latency_ms, + lint_latency_ms=lint_latency_ms, + evaluation_latency_ms=evaluation_latency_ms, + quality_pass_ms=round(float(quality_pass_timing_ms.get("total_ms", 0.0) or 0.0), 3), + issue_codes=[issue.get("issue_code") for issue in evaluation_payload.get("issues", []) if issue.get("issue_code")], + ) + except Exception: + pass if self.observability is not None: manifest = dict((version.worldpack_json or {}).get("manifest", {})) author_account_id = str(manifest.get("author_id") or "") or None @@ -1347,10 +6154,52 @@ def run_simulation_for_world_version( "latest_decision": reports[-1].decision.decision if reports else "rewrite", "chapter_evaluations": [report_item.to_dict() for report_item in reports], "chapter_trace": chapter_trace, + "final_state_snapshot": state.to_dict(), + "longform_plan_snapshot": { + "series_plan": dict(series_plan), + "volume_plans": [dict(item) for item in volume_plans], + "arc_plans": [dict(item) for item in arc_plans], + "chapter_budget_policy": dict(chapter_budget_policy), + "memory_compression_policy": resolved_memory_compression_policy, + "plan_source": plan_source, + }, + "steering_checkpoints": steering_checkpoints, + "replan_history": replan_history, + "memory_patch_summary": _build_memory_patch_summary(state), } if include_cross_pack: - cross_pack_summary = self._build_cross_pack_summary(version.world_id, world_version_id) + benchmark_mode = ( + "longform_1000_interactive" + if max_chapters >= 1000 and has_interactive_scenarios + else ( + "longform_1000_diagnostics" + if max_chapters >= 1000 + else ( + "longform_500_interactive" + if max_chapters >= 500 and has_interactive_scenarios + else ( + "longform_500" + if max_chapters >= 500 + else ( + "longform_250_interactive" + if max_chapters >= 250 and has_interactive_scenarios + else ( + "longform_250" + if max_chapters >= 250 + else ("longform_100_interactive" if has_interactive_scenarios else ("longform_100" if max_chapters >= 100 else None)) + ) + ) + ) + ) + ) + ) + cross_pack_summary = self._build_cross_pack_summary( + version.world_id, + world_version_id, + benchmark_mode=benchmark_mode, + max_chapters=max_chapters, + ) simulation_report["cross_pack_summary"] = cross_pack_summary simulation_report["top_failing_packs"] = cross_pack_summary.get("top_failing_packs", []) simulation_report["metric_deltas"] = ( @@ -1367,10 +6216,349 @@ def run_simulation_for_world_version( simulation_report["learned_shadow_summary"] = self.learned_shadow.summarize( simulation_report["learned_evaluation_summary"] ) + q09_incidence_rate = round( + sum( + 1 + for payload in simulation_report["chapter_evaluations"] + if any(issue.get("issue_code") == "Q09" for issue in payload.get("issues", [])) + ) + / float(max(1, completed_chapters or 1)), + 3, + ) + character_drift_rate = round( + sum( + 1 + for payload in simulation_report["chapter_evaluations"] + if any(issue.get("issue_code") == "Q06" for issue in payload.get("issues", [])) + ) + / float(max(1, completed_chapters or 1)), + 3, + ) + volume_climax_spacing_error = _volume_climax_spacing_error(chapter_trace, volume_plans) + premature_ending_trigger_rate = q09_incidence_rate + pass_windows = _longform_pass_windows(simulation_report["chapter_evaluations"]) + simulation_report["longform_summary"] = { + "series_id": series_plan.get("series_id"), + "plan_source": plan_source, + "volume_count": len(volume_plans), + "arc_count": len(arc_plans), + "target_chapters": int(series_plan.get("total_chapter_target", max_chapters) or max_chapters), + "chapter_budget_target_words": state.word_budget, + "character_drift_rate": character_drift_rate, + "promise_unresolved_rate": round(len(state.open_promises) / float(max(1, completed_chapters or 1)), 3), + "arc_task_repeat_rate": _chapter_task_repeat_rate(chapter_trace), + "q09_incidence_rate": q09_incidence_rate, + "mid_arc_pass_rate": float(pass_windows["mid_arc_pass_rate"]), + "late_arc_pass_rate": float(pass_windows["late_arc_pass_rate"]), + "premature_ending_trigger_rate": premature_ending_trigger_rate, + "volume_climax_spacing_error": volume_climax_spacing_error, + } + gate_target_chapters = ( + int(simulation_report["longform_summary"]["target_chapters"]) + if max_chapters >= 100 + else int(max_chapters) + ) + simulation_report["longform_gate"] = evaluate_longform_gate( + target_chapters=gate_target_chapters, + completed_chapters=completed_chapters, + pass_rate=float(aggregate.get("pass_rate", 0.0)), + block_rate=float(aggregate.get("block_rate", 0.0)), + stop_reason=str(simulation_report.get("stop_reason", "")), + completion_ratio=float(simulation_report.get("completion_ratio", 0.0)), + mid_arc_pass_rate=float(pass_windows["mid_arc_pass_rate"]), + q09_incidence_rate=q09_incidence_rate, + character_drift_rate=character_drift_rate, + promise_unresolved_rate=float(simulation_report["longform_summary"]["promise_unresolved_rate"]), + arc_task_repeat_rate=float(simulation_report["longform_summary"]["arc_task_repeat_rate"]), + premature_ending_trigger_rate=premature_ending_trigger_rate, + volume_climax_spacing_error=volume_climax_spacing_error, + ) + if max_chapters >= 250: + completed_volume_ids = [ + snapshot.get("volume_id") + for snapshot in list(state.volume_memory_snapshots or []) + if snapshot.get("volume_id") + ] + completed_volume_count = len({str(item) for item in completed_volume_ids}) + target_volume_count = max(1, len(volume_plans)) + replan_metrics = dict(state.replan_stability_metrics or {}) + replan_events = [dict(item) for item in list(state.replan_history or [])] + effective_replan_events = ( + [ + item + for item in replan_events + if not str(item.get("reason") or "").startswith("steering::") + ] + if has_interactive_scenarios + else list(replan_events) + ) + strong_replans = sum(1 for item in effective_replan_events if str(item.get("mode") or "") == "strong") + total_replans = len(effective_replan_events) + longform_250_summary = { + "target_chapters": 250, + "target_volume_count": target_volume_count, + "completed_volume_count": completed_volume_count, + "volume_boundary_survival": round(completed_volume_count / float(max(1, target_volume_count)), 3), + "memory_recall_coverage": round( + len([item for item in list(state.volume_memory_snapshots or []) if (item.get("active_unresolved_promise_ids") or item.get("character_memory_refs"))]) + / float(max(1, len(list(state.volume_memory_snapshots or [])) or 1)), + 3, + ) if list(state.volume_memory_snapshots or []) else 0.0, + "replan_stability_score": round( + max(0.0, 1.0 - (strong_replans / float(max(1, total_replans or 1)))), + 3, + ) if total_replans else 1.0, + "replan_stability_mode": "steering_adjusted" if has_interactive_scenarios else "static", + "steering_replans_excluded": ( + len(replan_events) - len(effective_replan_events) + if has_interactive_scenarios + else 0 + ), + "volume_snapshot_integrity": round( + len(list(state.volume_memory_snapshots or [])) / float(max(1, completed_volume_count or 1)), + 3, + ) if completed_volume_count else 0.0, + "mid_volume_pass_rate": float(pass_windows["mid_arc_pass_rate"]), + "late_volume_pass_rate": float(pass_windows["late_arc_pass_rate"]), + } + simulation_report["longform_250_summary"] = longform_250_summary + failed_checks = [] + if float(longform_250_summary["volume_boundary_survival"]) < 1.0: + failed_checks.append("volume_boundary_survival") + if float(longform_250_summary["memory_recall_coverage"]) < 0.5: + failed_checks.append("memory_recall_coverage") + if float(longform_250_summary["replan_stability_score"]) < 0.67: + failed_checks.append("replan_stability_score") + if float(longform_250_summary["volume_snapshot_integrity"]) < 1.0: + failed_checks.append("volume_snapshot_integrity") + simulation_report["longform_250_evidence"] = { + "status": "ready" if not failed_checks else "watch", + "failed_checks": failed_checks, + "summary": dict(longform_250_summary), + } + if max_chapters >= 500: + series_snapshots = [dict(item) for item in list(state.series_memory_snapshots or [])] + target_volume_count = max(1, len(volume_plans)) + policy = dict(resolved_memory_compression_policy or {}) + every_n_volumes = max(1, int(policy.get("series_snapshot_every_n_volumes", 2) or 2)) + expected_series_snapshots = max(1, (target_volume_count + every_n_volumes - 1) // every_n_volumes) + retained_series_snapshot_target = min( + expected_series_snapshots, + max(1, int(policy.get("series_snapshot_limit", expected_series_snapshots) or expected_series_snapshots)), + ) + series_ending_checkpoint = dict(state.series_ending_checkpoint or {}) + longform_500_summary = { + "target_chapters": 500, + "target_volume_count": target_volume_count, + "completed_volume_count": len({str(item.get('volume_id') or '') for item in list(state.volume_memory_snapshots or []) if str(item.get('volume_id') or '')}), + "series_boundary_survival": round( + len({str(item.get('volume_id') or '') for item in list(state.volume_memory_snapshots or []) if str(item.get('volume_id') or '')}) + / float(max(1, target_volume_count)), + 3, + ), + "series_snapshot_count": len(series_snapshots), + "expected_series_snapshots": expected_series_snapshots, + "retained_series_snapshot_target": retained_series_snapshot_target, + "series_memory_snapshot_integrity": round( + min(1.0, len(series_snapshots) / float(max(1, retained_series_snapshot_target))), + 3, + ), + "memory_recall_coverage": round( + len([item for item in series_snapshots if (item.get("active_unresolved_promise_ids") or item.get("character_memory_refs"))]) + / float(max(1, len(series_snapshots) or 1)), + 3, + ) if series_snapshots else 0.0, + "replan_stability_score": round(float((simulation_report.get("longform_250_summary") or {}).get("replan_stability_score", 0.0) or 0.0), 3), + "late_series_pass_rate": float(pass_windows["late_arc_pass_rate"]), + "series_ending_control_score": 1.0 if bool(series_ending_checkpoint.get("terminal_ready")) else 0.0, + "series_ending_status": str(series_ending_checkpoint.get("status") or "missing"), + } + simulation_report["longform_500_summary"] = longform_500_summary + failed_checks = [] + if float(longform_500_summary["series_boundary_survival"]) < 1.0: + failed_checks.append("series_boundary_survival") + if float(longform_500_summary["series_memory_snapshot_integrity"]) < 1.0: + failed_checks.append("series_memory_snapshot_integrity") + if float(longform_500_summary["memory_recall_coverage"]) < 0.5: + failed_checks.append("series_memory_recall_coverage") + if float(longform_500_summary["replan_stability_score"]) < 0.67: + failed_checks.append("replan_stability_score") + if float(longform_500_summary["late_series_pass_rate"]) < 0.8: + failed_checks.append("late_series_pass_rate") + if float(longform_500_summary["series_ending_control_score"]) < 1.0: + failed_checks.append("series_ending_control_score") + simulation_report["longform_500_evidence"] = { + "status": "ready" if not failed_checks else "watch", + "failed_checks": failed_checks, + "summary": dict(longform_500_summary), + } + if max_chapters >= 1000: + policy = dict(resolved_memory_compression_policy or {}) + archive_limit = max(1, int(policy.get("archive_retention_limit", 160) or 160)) + timeline_limit = max(1, int(policy.get("timeline_retention_limit", 240) or 240)) + continuation_fact_limit = max(1, int(policy.get("continuation_fact_retention_limit", 120) or 120)) + continuation_visit_limit = max(1, int(policy.get("continuation_visit_retention_limit", 120) or 120)) + archive_count = len(list(state.archive_memory or [])) + timeline_count = len(list(state.timeline or [])) + continuation_fact_count = len([item for item in list(state.world_facts or []) if str(item).startswith("continuation::")]) + continuation_visit_count = len([item for item in list(state.visited_event_ids or []) if "__continuation__" in str(item)]) + late_stage_latencies = [ + float(item.get("runtime_latency_ms", 0.0) or 0.0) + for item in chapter_trace + if int( + item.get("simulation_chapter_index") + or dict(item.get("chapter_task_execution_summary") or {}).get("series_chapter_index") + or 0 + ) >= 750 + and item.get("runtime_latency_ms") is not None + ] + every_n_volumes = max(1, int(policy.get("series_snapshot_every_n_volumes", 2) or 2)) + target_volume_count = max(1, len(volume_plans)) + expected_series_snapshots = max(1, (target_volume_count + every_n_volumes - 1) // every_n_volumes) + retained_series_snapshot_target = min( + expected_series_snapshots, + max(1, int(policy.get("series_snapshot_limit", expected_series_snapshots) or expected_series_snapshots)), + ) + runtime_p95_ms = _percentile(late_stage_latencies, 0.95) if late_stage_latencies else 0.0 + runtime_max_ms = round(max(late_stage_latencies), 3) if late_stage_latencies else 0.0 + runtime_budget_score = 1.0 if runtime_p95_ms <= 2500 else round(max(0.0, 1.0 - ((runtime_p95_ms - 2500.0) / 7500.0)), 3) + archive_retention_integrity = round(min(1.0, archive_limit / float(max(1, archive_count))), 3) + timeline_retention_integrity = round(min(1.0, timeline_limit / float(max(1, timeline_count))), 3) + continuation_state_retention_integrity = round( + min( + 1.0, + continuation_fact_limit / float(max(1, continuation_fact_count)), + continuation_visit_limit / float(max(1, continuation_visit_count)), + ), + 3, + ) + longform_1000_summary = { + "target_chapters": 1000, + "series_boundary_survival": round(float((simulation_report.get("longform_500_summary") or {}).get("series_boundary_survival", 0.0) or 0.0), 3), + "series_memory_snapshot_integrity": round(float((simulation_report.get("longform_500_summary") or {}).get("series_memory_snapshot_integrity", 0.0) or 0.0), 3), + "memory_recall_coverage": round(float((simulation_report.get("longform_500_summary") or {}).get("memory_recall_coverage", 0.0) or 0.0), 3), + "replan_stability_score": round(float((simulation_report.get("longform_500_summary") or {}).get("replan_stability_score", 0.0) or 0.0), 3), + "late_series_pass_rate": round(float((simulation_report.get("longform_500_summary") or {}).get("late_series_pass_rate", 0.0) or 0.0), 3), + "series_ending_control_score": round(float((simulation_report.get("longform_500_summary") or {}).get("series_ending_control_score", 0.0) or 0.0), 3), + "archive_retention_integrity": archive_retention_integrity, + "timeline_retention_integrity": timeline_retention_integrity, + "continuation_state_retention_integrity": continuation_state_retention_integrity, + "late_stage_runtime_p95_ms": runtime_p95_ms, + "late_stage_runtime_max_ms": runtime_max_ms, + "late_stage_runtime_budget_score": runtime_budget_score, + "series_snapshot_count": len(list(state.series_memory_snapshots or [])), + "expected_series_snapshots": expected_series_snapshots, + "retained_series_snapshot_target": retained_series_snapshot_target, + "series_ending_status": str(dict(state.series_ending_checkpoint or {}).get("status") or "missing"), + } + simulation_report["longform_1000_summary"] = longform_1000_summary + failed_checks = [] + if float(longform_1000_summary["series_boundary_survival"]) < 1.0: + failed_checks.append("series_boundary_survival") + if float(longform_1000_summary["series_memory_snapshot_integrity"]) < 1.0: + failed_checks.append("series_memory_snapshot_integrity") + if float(longform_1000_summary["archive_retention_integrity"]) < 1.0: + failed_checks.append("archive_retention_integrity") + if float(longform_1000_summary["timeline_retention_integrity"]) < 1.0: + failed_checks.append("timeline_retention_integrity") + if float(longform_1000_summary["continuation_state_retention_integrity"]) < 1.0: + failed_checks.append("continuation_state_retention_integrity") + if float(longform_1000_summary["late_stage_runtime_budget_score"]) < 0.67: + failed_checks.append("late_stage_runtime_budget_score") + if float(longform_1000_summary["series_ending_control_score"]) < 1.0: + failed_checks.append("series_ending_control_score") + simulation_report["longform_1000_evidence"] = { + "status": "promising" if not failed_checks else "watch", + "failed_checks": failed_checks, + "summary": dict(longform_1000_summary), + } simulation_report["simulation_drilldown"] = self._build_simulation_drilldown(simulation_report) + simulation_report["longform_drilldown"] = self._build_longform_drilldown(simulation_report) + simulation_report["creative_cockpit"] = self._build_creative_cockpit(version.worldpack_json, simulation_report) + simulation_report["content_quality_contract_window_metrics"] = self._content_quality_window_metrics( + version.worldpack_json, + simulation_report, + ) + simulation_report["content_quality_repair_workbench"] = self._build_content_quality_repair_workbench( + version.worldpack_json, + simulation_report, + ) + simulation_report["latest_repair_loop_outcome"] = {} + simulation_report["repair_loop_history"] = [] + simulation_report["latest_strategy_bundle_execution"] = {} + simulation_report["strategy_bundle_execution_history"] = [] + if steering_checkpoints: + scenario_results = [] + for checkpoint in steering_checkpoints: + chapter_index = int(checkpoint.get("chapter_index", 0) or 0) + post_short = [ + item for item in chapter_trace + if chapter_index < int(dict(item.get("chapter_task_execution_summary") or {}).get("series_chapter_index", 0) or 0) <= chapter_index + 3 + ] + post_long = [ + item for item in chapter_trace + if chapter_index < int(dict(item.get("chapter_task_execution_summary") or {}).get("series_chapter_index", 0) or 0) <= chapter_index + 10 + ] + short_reports = [item for item in simulation_report["chapter_evaluations"] if chapter_index < int(str(item.get("chapter_id", "")).rsplit("_", 1)[-1] or 0) <= chapter_index + 3] + long_reports = [item for item in simulation_report["chapter_evaluations"] if chapter_index < int(str(item.get("chapter_id", "")).rsplit("_", 1)[-1] or 0) <= chapter_index + 10] + q06_rate = round( + sum(1 for payload in long_reports if any(issue.get("issue_code") == "Q06" for issue in payload.get("issues", []))) + / float(max(1, len(long_reports) or 1)), + 3, + ) + q07_q09_rate = round( + sum( + 1 + for payload in long_reports + if any(issue.get("issue_code") in {"Q07", "Q09"} for issue in payload.get("issues", [])) + ) + / float(max(1, len(long_reports) or 1)), + 3, + ) + short_window = _issue_window_summary(short_reports, target_chapters=max_chapters) + long_window = _issue_window_summary(long_reports, target_chapters=max_chapters) + recovery = len(post_short) >= 3 + scenario_results.append( + { + **checkpoint, + "post_steer_short_window_chapters": len(post_short), + "post_steer_long_window_chapters": len(post_long), + "recovered": recovery, + "memory_consistency_score": round(1.0 - q06_rate, 3), + "promise_reconciliation_score": round(1.0 - q07_q09_rate, 3), + "replan_stability_score": 1.0 if recovery else 0.0, + "short_window": short_window, + "long_window": long_window, + } + ) + simulation_report["interactive_summary"] = { + "scenario_results": scenario_results, + "scenario_count": len(scenario_results), + "steering_recovery_rate": round(sum(1.0 for item in scenario_results if item.get("recovered")) / float(max(1, len(scenario_results))), 3), + "post_steer_route_survival": round(sum(float(item.get("post_steer_long_window_chapters", 0)) for item in scenario_results) / float(max(1, len(scenario_results) * 10)), 3), + "memory_consistency_after_steer": round(sum(float(item.get("memory_consistency_score", 0.0)) for item in scenario_results) / float(max(1, len(scenario_results))), 3), + "promise_reconciliation_after_steer": round(sum(float(item.get("promise_reconciliation_score", 0.0)) for item in scenario_results) / float(max(1, len(scenario_results))), 3), + "replan_stability_score": round(sum(float(item.get("replan_stability_score", 0.0)) for item in scenario_results) / float(max(1, len(scenario_results))), 3), + } + simulation_report["post_steer_issue_window_summary"] = [ + { + "scenario_id": item.get("scenario_id"), + "scenario_kind": item.get("scenario_kind"), + "chapter_index": item.get("chapter_index"), + "summary": item.get("summary"), + "short_window": dict(item.get("short_window") or {}), + "long_window": dict(item.get("long_window") or {}), + } + for item in scenario_results + ] metadata = dict((version.worldpack_json or {}).get("metadata", {})) revision_history = list(metadata.get("revision_history", [])) + latest_repair_loop_outcome = self._build_repair_loop_outcome( + revision_history, + current_issue_groups=list((simulation_report.get("creative_cockpit") or {}).get("chapter_heatmap", {}).get("issue_priority_groups", [])), + current_chapter_heatmap=list((simulation_report.get("creative_cockpit") or {}).get("chapter_heatmap", {}).get("chapters", [])), + ) if revision_history: revision_history[-1]["simulation_delta"] = { "pass_rate_delta": simulation_report.get("metric_deltas", {}).get("pass_rate_delta"), @@ -1379,8 +6567,25 @@ def run_simulation_for_world_version( "metric_deltas": dict(simulation_report.get("metric_deltas", {})), } revision_history[-1]["simulation_snapshot"] = self._simulation_snapshot(simulation_report) + if latest_repair_loop_outcome.get("repair_loop_revision_id"): + for revision in revision_history: + if revision.get("revision_id") == latest_repair_loop_outcome["repair_loop_revision_id"]: + revision["repair_loop_outcome"] = copy.deepcopy(latest_repair_loop_outcome) + break metadata["revision_history"] = revision_history[-10:] version.worldpack_json["metadata"] = metadata + simulation_report["latest_repair_loop_outcome"] = latest_repair_loop_outcome + simulation_report["repair_loop_history"] = self._repair_loop_history(list(metadata.get("revision_history", []))) + simulation_report["latest_strategy_bundle_execution"] = self._latest_strategy_bundle_execution( + list(metadata.get("revision_history", [])) + ) + simulation_report["strategy_bundle_execution_history"] = self._strategy_bundle_execution_history( + list(metadata.get("revision_history", [])) + ) + simulation_report["content_quality_repair_workbench"] = self._build_content_quality_repair_workbench( + version.worldpack_json, + simulation_report, + ) version.simulation_report_json = simulation_report self.repository.save_world_version(version, publish=False) @@ -1432,7 +6637,7 @@ def _genre_preset(preset_id: str) -> Dict[str, Any]: "tonal_lexicon": ["门第", "体面", "牵连", "旧账"], "thematic_axis_labels": {"duty": "责任与牵引", "love": "情意与靠近", "reputation": "名声与体面"}, "hook_templates": ["这层体面先撑住了,可真正会追上来的,是那句被压回去的心里话。"], - "dialogue_realism_policy": {"policy_id": "jade_brief_dialogue", "require_turn_taking": True, "require_counter_reaction": True, "min_turns": 2, "max_turns": 3, "turn_pattern": ["speaker", "reaction", "reply"], "minimum_exchanges": 1}, + "dialogue_realism_policy": {"policy_id": "jade_brief_dialogue", "require_turn_taking": True, "require_counter_reaction": True, "min_turns": 3, "max_turns": 4, "turn_pattern": ["speaker", "reaction", "reply", "echo"], "minimum_exchanges": 2}, }, "urban_mystery": { "title": "旧巷回潮", @@ -1449,7 +6654,7 @@ def _genre_preset(preset_id: str) -> Dict[str, Any]: "tonal_lexicon": ["旧账", "巷口", "回声", "试探"], "thematic_axis_labels": {"urban_mystery": "真相与羞耻", "truth": "真相与揭露", "suspense": "悬疑与压迫"}, "hook_templates": ["夜色先退了一步,可真正让人睡不着的,是下一次见面时还要不要继续问下去。"], - "dialogue_realism_policy": {"policy_id": "urban_brief_dialogue", "require_turn_taking": True, "require_counter_reaction": True, "min_turns": 2, "max_turns": 3, "turn_pattern": ["speaker", "reaction", "reply"], "minimum_exchanges": 1}, + "dialogue_realism_policy": {"policy_id": "urban_brief_dialogue", "require_turn_taking": True, "require_counter_reaction": True, "min_turns": 3, "max_turns": 4, "turn_pattern": ["speaker", "reaction", "reply", "echo"], "minimum_exchanges": 2}, }, "xianxia": { "title": "旧誓照骨", @@ -1466,7 +6671,7 @@ def _genre_preset(preset_id: str) -> Dict[str, Any]: "tonal_lexicon": ["旧誓", "反噬", "灵息", "山门"], "thematic_axis_labels": {"xianxia": "誓愿与天命", "destiny": "命运的去向", "truth": "真相与揭露"}, "hook_templates": ["这一句先落在这里,可真正会逼人回头的,是下一次相见时还要不要认这层旧誓。"], - "dialogue_realism_policy": {"policy_id": "xianxia_brief_dialogue", "require_turn_taking": True, "require_counter_reaction": True, "min_turns": 2, "max_turns": 3, "turn_pattern": ["speaker", "reaction", "reply"], "minimum_exchanges": 1}, + "dialogue_realism_policy": {"policy_id": "xianxia_brief_dialogue", "require_turn_taking": True, "require_counter_reaction": True, "min_turns": 3, "max_turns": 4, "turn_pattern": ["speaker", "reaction", "reply", "echo"], "minimum_exchanges": 2}, }, "synthetic": { "title": "最小实验世界", @@ -1483,12 +6688,769 @@ def _genre_preset(preset_id: str) -> Dict[str, Any]: "tonal_lexicon": ["试探", "回声", "选择", "停顿"], "thematic_axis_labels": {"synthetic": "试探与选择", "truth": "真相与揭露", "selfhood": "自我与抉择"}, "hook_templates": ["这层平静先撑住了,可真正要追上来的,是那句被按回去的真话。"], - "dialogue_realism_policy": {"policy_id": "synthetic_brief_dialogue", "require_turn_taking": True, "require_counter_reaction": True, "min_turns": 2, "max_turns": 3, "turn_pattern": ["speaker", "reaction", "reply"], "minimum_exchanges": 1}, + "dialogue_realism_policy": {"policy_id": "synthetic_brief_dialogue", "require_turn_taking": True, "require_counter_reaction": True, "min_turns": 3, "max_turns": 4, "turn_pattern": ["speaker", "reaction", "reply", "echo"], "minimum_exchanges": 2}, }, } return dict(presets.get(preset_id, presets["urban_mystery"])) +def _chapter_task_repeat_rate(chapter_trace: List[Dict[str, Any]]) -> float: + duties = [str((item.get("chapter_task") or {}).get("duty_type") or "") for item in chapter_trace if (item.get("chapter_task") or {}).get("duty_type")] + if len(duties) < 2: + return 0.0 + repeats = 0 + for index in range(1, len(duties)): + if duties[index] == duties[index - 1]: + repeats += 1 + return round(repeats / float(max(1, len(duties) - 1)), 3) + + +def _volume_climax_spacing_error(chapter_trace: List[Dict[str, Any]], volume_plans: List[Dict[str, Any]]) -> float: + if not chapter_trace or not volume_plans: + return 0.0 + actual_counts: Dict[str, int] = {} + for item in chapter_trace: + volume_id = str(item.get("volume_id") or "") + if not volume_id: + continue + actual_counts[volume_id] = actual_counts.get(volume_id, 0) + 1 + deltas = [] + for volume in volume_plans: + volume_id = str(volume.get("volume_id") or "") + target = max(1, int(volume.get("target_chapters", 1))) + actual = int(actual_counts.get(volume_id, 0)) + deltas.append(abs(actual - target) / float(target)) + return round(sum(deltas) / float(max(1, len(deltas))), 3) + + +def _longform_pass_windows(chapter_evaluations: List[Dict[str, Any]]) -> Dict[str, float]: + if not chapter_evaluations: + return { + "mid_arc_pass_rate": 0.0, + "late_arc_pass_rate": 0.0, + } + decisions = [str((item.get("decision") or {}).get("decision", "rewrite")) for item in chapter_evaluations] + first_end = max(1, len(chapter_evaluations) // 3) + mid_end = max(first_end + 1, (2 * len(chapter_evaluations)) // 3) + middle_decisions = decisions[first_end:mid_end] or decisions[-1:] + late_decisions = decisions[mid_end:] or decisions[-1:] + return { + "mid_arc_pass_rate": round( + sum(1 for decision in middle_decisions if decision == "pass") / float(max(1, len(middle_decisions))), + 3, + ), + "late_arc_pass_rate": round( + sum(1 for decision in late_decisions if decision == "pass") / float(max(1, len(late_decisions))), + 3, + ), + } + + +def _issue_window_summary( + chapter_evaluations: List[Dict[str, Any]], + *, + target_chapters: int, + issue_codes: tuple[str, ...] = INTERACTIVE_WINDOW_ISSUE_CODES, +) -> Dict[str, Any]: + issue_counts = { + issue_code: sum( + 1 + for payload in chapter_evaluations + if ( + any(issue.get("issue_code") == issue_code for issue in payload.get("issues", [])) + or issue_code in diagnostic_issue_codes_for_chapter_payload(payload, target_chapters=target_chapters) + ) + ) + for issue_code in issue_codes + } + chapter_count = len(chapter_evaluations) + return { + "chapter_count": chapter_count, + "issue_counts": issue_counts, + "issue_rates": { + issue_code: round(issue_counts[issue_code] / float(max(1, chapter_count)), 3) + for issue_code in issue_codes + }, + } + + +def _build_memory_patch_summary(state: NarrativeState) -> Dict[str, Any]: + runtime = dict(state.character_memory_runtime or {}) + pending_count = 0 + adopted_count = 0 + characters_with_pending: List[str] = [] + characters_with_adopted: List[str] = [] + for character_id, payload in runtime.items(): + entry = dict(payload or {}) + pending = [dict(item) for item in entry.get("pending_memory_patches", [])] + adopted = [dict(item) for item in entry.get("adopted_memory_patches", [])] + pending_count += len(pending) + adopted_count += len(adopted) + if pending: + characters_with_pending.append(character_id) + if adopted: + characters_with_adopted.append(character_id) + return { + "pending_count": pending_count, + "adopted_count": adopted_count, + "characters_with_pending": characters_with_pending, + "characters_with_adopted": characters_with_adopted, + } + + +def _resolve_longform_theme(worldpack_payload: Dict[str, Any], runtime_world_title: str) -> str: + metadata = dict(worldpack_payload.get("metadata") or {}) + brief = dict(metadata.get("author_brief") or {}) + if str(brief.get("life_theme") or "").strip(): + return str(brief.get("life_theme")).strip() + manifest = dict(worldpack_payload.get("manifest") or {}) + genres = [str(item).strip() for item in manifest.get("genres", []) if str(item).strip()] + if genres: + return " / ".join(genres[:2]) + return runtime_world_title + + +def _resolve_longform_structure( + *, + worldpack_payload: Dict[str, Any], + runtime_world_title: str, + max_chapters: int, +) -> Dict[str, Any]: + series_plan = dict(worldpack_payload.get("series_plan") or {}) + volume_plans = list(worldpack_payload.get("volume_plans") or []) + arc_plans = list(worldpack_payload.get("arc_plans") or []) + chapter_budget_policy = dict(worldpack_payload.get("chapter_budget_policy") or {}) + if series_plan and volume_plans and arc_plans: + target_total_chapters = max(24, int(series_plan.get("total_chapter_target", max_chapters) or max_chapters)) + normalized_arc_plans = [] + for arc in arc_plans: + arc_payload = dict(arc or {}) + arc_payload["chapter_tasks"] = [ + ensure_chapter_task_quality_contract( + dict(task or {}), + target_chapters=target_total_chapters, + ) + for task in list(arc_payload.get("chapter_tasks") or []) + ] + normalized_arc_plans.append(arc_payload) + return { + "series_plan": series_plan, + "volume_plans": volume_plans, + "arc_plans": normalized_arc_plans, + "chapter_budget_policy": chapter_budget_policy, + "plan_source": "worldpack", + } + target_total_chapters = max(24, int(max_chapters)) + if target_total_chapters >= 1000: + target_total_volumes = max(16, min(20, target_total_chapters // 60 or 16)) + elif target_total_chapters >= 500: + target_total_volumes = max(8, min(10, target_total_chapters // 50 or 8)) + else: + target_total_volumes = max(3, min(5, target_total_chapters // 20 or 3)) + fallback = _build_longform_structure( + world_id=str(worldpack_payload.get("world_id") or _slugify_world_id(runtime_world_title)), + world_title=str(worldpack_payload.get("title") or runtime_world_title), + life_theme=_resolve_longform_theme(worldpack_payload, runtime_world_title), + target_total_chapters=target_total_chapters, + target_total_volumes=target_total_volumes, + target_word_count=target_total_chapters * 2000, + ) + fallback["plan_source"] = "runtime_fallback" + return fallback + + +def _bootstrap_longform_structure_payload( + *, + worldpack_payload: Dict[str, Any], + runtime_world_title: str, +) -> Dict[str, Any]: + metadata = dict(worldpack_payload.get("metadata") or {}) + brief = dict(metadata.get("author_brief") or {}) + target_total_chapters = max(24, int(brief.get("target_total_chapters") or 100)) + target_total_volumes = max(1, int(brief.get("target_total_volumes") or 5)) + target_word_count = max(20000, int(brief.get("target_word_count") or (target_total_chapters * 2000))) + return { + **_build_longform_structure( + world_id=str(worldpack_payload.get("world_id") or _slugify_world_id(runtime_world_title)), + world_title=str(worldpack_payload.get("title") or runtime_world_title), + life_theme=_resolve_longform_theme(worldpack_payload, runtime_world_title), + target_total_chapters=target_total_chapters, + target_total_volumes=target_total_volumes, + target_word_count=target_word_count, + ), + "plan_source": "workbench_bootstrap", + } + + +def _build_storyline_contract_from_brief( + *, + world_title: str, + core_premise: str, + life_theme: str, + volume_plans: List[Dict[str, Any]], +) -> Dict[str, Any]: + milestones = [] + cumulative = 0 + for volume in volume_plans: + cumulative += max(1, int(volume.get("target_chapters", 1) or 1)) + milestones.append( + { + "milestone_id": str(volume.get("volume_id") or f"milestone_{len(milestones) + 1}"), + "label": str(volume.get("title") or volume.get("goal") or "长篇阶段目标"), + "target_chapter": cumulative, + "status": "planned", + } + ) + protected_themes = [item for item in [life_theme, core_premise] if item] + return { + "core_storyline": core_premise or world_title, + "storyline_summary": core_premise or world_title, + "protected_themes": protected_themes, + "no_early_ending": True, + "milestones": milestones, + "conflict_policy": "reconcile_and_carry_forward", + } + + +def _build_character_memory_profiles_from_characters(characters: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + profiles: Dict[str, Dict[str, Any]] = {} + for character in characters: + character_id = str(character.get("character_id") or "") + if not character_id: + continue + destiny = dict(character.get("destiny_contract") or {}) + vow_profile = dict(character.get("vow_profile") or {}) + wound = dict(character.get("wound_profile") or {}) + profiles[character_id] = { + "structured_memory": { + "relationship_history": [], + "promises": list(vow_profile.get("vows", [])), + "secrets": [], + "scars": [str(wound.get("core_wound") or "")] if wound.get("core_wound") else [], + "faction": "", + "taboos": list(destiny.get("forbidden_escape", [])) if isinstance(destiny.get("forbidden_escape"), list) else ([str(destiny.get("forbidden_escape"))] if destiny.get("forbidden_escape") else []), + "goals": [str(destiny.get("life_theme") or "")] if destiny.get("life_theme") else [], + }, + "free_text_memory": [], + } + return profiles + + +def _ensure_character_memory_profile_coverage(worldpack_payload: Dict[str, Any]) -> None: + characters = [dict(item) for item in list(worldpack_payload.get("characters") or [])] + existing_profiles = { + str(key): copy.deepcopy(dict(value or {})) + for key, value in dict(worldpack_payload.get("character_memory_profiles") or {}).items() + if str(key).strip() + } + generated_profiles = _build_character_memory_profiles_from_characters(characters) + for character_id, generated in generated_profiles.items(): + current = existing_profiles.get(character_id, {}) + current_structured = dict(current.get("structured_memory") or {}) + generated_structured = dict(generated.get("structured_memory") or {}) + for key, value in generated_structured.items(): + current_structured.setdefault(key, copy.deepcopy(value)) + current["structured_memory"] = current_structured + current.setdefault("free_text_memory", list(generated.get("free_text_memory") or [])) + existing_profiles[character_id] = current + worldpack_payload["character_memory_profiles"] = existing_profiles + + +def _ensure_character_asset_coverage( + worldpack_payload: Dict[str, Any], + *, + preset_id: Optional[str] = None, +) -> None: + _ensure_character_memory_profile_coverage(worldpack_payload) + + +def _default_steering_guardrails() -> Dict[str, Any]: + return { + "replan_future_only": True, + "no_past_rewrite": True, + "conflict_policy": "reconcile_and_carry_forward", + "no_early_ending": True, + } + + +def _default_memory_compression_policy(total_volumes: int) -> Dict[str, Any]: + volume_target = max(1, int(total_volumes or 1)) + if volume_target >= 16: + return { + "rolling_recap_limit": 4, + "active_arc_memory_limit": 8, + "archive_retrieval_limit": 8, + "archive_retention_limit": 64, + "series_archive_prune_margin_chapters": 20, + "volume_snapshot_every_n_chapters": 1, + "promote_memory_on_reference_count": 2, + "volume_context_window": 1, + "series_snapshot_every_n_volumes": 3, + "series_snapshot_limit": 6, + "series_ending_activation_window_chapters": 80, + "series_terminal_min_completion_ratio": 0.985, + "timeline_retention_limit": 64, + "continuation_fact_retention_limit": 48, + "continuation_visit_retention_limit": 48, + "target_volume_count": volume_target, + } + return { + "rolling_recap_limit": 6 if volume_target >= 8 else 8, + "active_arc_memory_limit": 10 if volume_target >= 8 else 12, + "archive_retrieval_limit": 10 if volume_target >= 8 else 12, + "archive_retention_limit": 80 if volume_target >= 8 else 160, + "series_archive_prune_margin_chapters": 30 if volume_target >= 8 else 40, + "volume_snapshot_every_n_chapters": 1, + "promote_memory_on_reference_count": 2, + "volume_context_window": 1 if volume_target >= 8 else 2, + "series_snapshot_every_n_volumes": 2, + "series_snapshot_limit": 5 if volume_target >= 8 else 3, + "series_ending_activation_window_chapters": 40 if volume_target >= 8 else 30, + "series_terminal_min_completion_ratio": 0.97 if volume_target >= 8 else 0.96, + "timeline_retention_limit": 80 if volume_target >= 8 else 240, + "continuation_fact_retention_limit": 60 if volume_target >= 8 else 120, + "continuation_visit_retention_limit": 60 if volume_target >= 8 else 120, + "target_volume_count": volume_target, + } + + +def _preset_name_components(preset_id: str) -> Dict[str, List[str]]: + return dict(LONGFORM_EXTRA_NAME_COMPONENTS.get(preset_id) or LONGFORM_EXTRA_NAME_COMPONENTS["synthetic"]) + + +def _generate_longform_name(preset_id: str, index: int) -> str: + components = _preset_name_components(preset_id) + surnames = list(components.get("surnames") or ["甲"]) + givens = list(components.get("givens") or ["一"]) + surname = surnames[index % len(surnames)] + given = givens[(index * 3) % len(givens)] + return f"{surname}{given}" + + +def _next_longform_character_blueprints( + *, + preset_id: str, + life_theme: str, + existing_character_ids: List[str], + current_count: int, + target_count: int, +) -> List[Dict[str, Any]]: + generated: List[Dict[str, Any]] = [] + used_ids = set(existing_character_ids) + next_index = current_count + while len(existing_character_ids) + len(generated) < target_count: + next_index += 1 + role = LONGFORM_EXTRA_ROLE_CYCLE[(next_index - 1) % len(LONGFORM_EXTRA_ROLE_CYCLE)] + character_id = f"supporting_{next_index}" + if character_id in used_ids: + continue + used_ids.add(character_id) + name = _generate_longform_name(preset_id, next_index - 1) + wound = LONGFORM_EXTRA_WOUND_POOL[(next_index - 1) % len(LONGFORM_EXTRA_WOUND_POOL)] + public_self = LONGFORM_EXTRA_PUBLIC_SELF_POOL[(next_index - 1) % len(LONGFORM_EXTRA_PUBLIC_SELF_POOL)] + shadow_desire = LONGFORM_EXTRA_SHADOW_DESIRE_POOL[(next_index - 1) % len(LONGFORM_EXTRA_SHADOW_DESIRE_POOL)] + vow = LONGFORM_EXTRA_VOW_POOL[(next_index - 1) % len(LONGFORM_EXTRA_VOW_POOL)] + generated.append( + { + "character_id": character_id, + "display_name": name, + "role": role, + "destiny_contract": {"life_theme": life_theme or "把更长的代价和关系张力撑到下一卷。"}, + "poison_vector": {"greed": 0.18, "anger": 0.22, "delusion": 0.24, "pride": 0.38, "doubt": 0.36}, + "vow_profile": {"vows": [vow], "sacrifice_capacity": 0.46, "truth_tolerance": 0.58}, + "wound_profile": { + "core_wound": wound, + "public_self": public_self, + "shadow_desire": shadow_desire, + "defense_style": "先稳局面再认代价", + }, + "awakening_profile": { + "clarity": 0.44, + "reflection_capacity": 0.56, + "repentance_threshold": 0.68, + "transformation_paths": ["承认", "补偿", "站队"], + }, + "speech_traits": ["稳着说", "先收口"], + "action_traits": ["观望", "压场", "追索"], + } + ) + return generated + + +def _next_longform_locations(*, preset_id: str, existing_locations: List[str], target_count: int) -> List[str]: + normalized_existing = [str(item).strip() for item in existing_locations if str(item).strip()] + if len(normalized_existing) >= target_count: + return normalized_existing + pool = list(LONGFORM_EXTRA_LOCATION_POOLS.get(preset_id) or LONGFORM_EXTRA_LOCATION_POOLS["synthetic"]) + for location in pool: + if len(normalized_existing) >= target_count: + break + if location not in normalized_existing: + normalized_existing.append(location) + next_index = 1 + while len(normalized_existing) < target_count: + candidate = f"{preset_id}_extended_location_{next_index}" + if candidate not in normalized_existing: + normalized_existing.append(candidate) + next_index += 1 + return normalized_existing + + +def _next_longform_scene_blueprints( + *, + preset_id: str, + existing_scenes: List[Dict[str, Any]], + character_ids: List[str], + target_count: int, + desired_scene_family_count: int = 0, + desired_role_pair_count: int = 0, +) -> List[Dict[str, Any]]: + next_scenes = [ensure_scene_quality_contract(dict(item)) for item in existing_scenes] + existing_ids = {str(item.get("scene_id") or "") for item in next_scenes} + existing_functions = { + str(item.get("scene_function") or "").strip() + for item in next_scenes + if str(item.get("scene_function") or "").strip() + } + existing_role_pairs = { + " / ".join(sorted([str(role).strip() for role in list((item or {}).get("required_roles") or []) if str(role).strip()])) + for item in next_scenes + if len([str(role).strip() for role in list((item or {}).get("required_roles") or []) if str(role).strip()]) >= 2 + } + required_roles_pool = list(character_ids or ["lead", "counterpart"]) + if len(required_roles_pool) == 1: + required_roles_pool.append(required_roles_pool[0]) + + def needs_more() -> bool: + return ( + len(next_scenes) < target_count + or len(existing_functions) < desired_scene_family_count + or len(existing_role_pairs) < desired_role_pair_count + ) + + def select_role_pair(seed_index: int) -> List[str]: + if len(required_roles_pool) < 2: + return required_roles_pool[:1] or ["lead"] + best_pair = None + for offset in range(len(required_roles_pool)): + role_start = (seed_index + offset) % len(required_roles_pool) + candidate = [ + required_roles_pool[role_start], + required_roles_pool[(role_start + 1) % len(required_roles_pool)], + ] + pair_key = " / ".join(sorted(candidate)) + if pair_key not in existing_role_pairs: + best_pair = candidate + break + if best_pair is None: + best_pair = candidate + return best_pair or required_roles_pool[:2] + + def append_scene(scene_id: str, scene_function: str, beats: List[str], seed_index: int) -> None: + role_pair = select_role_pair(seed_index) + next_scenes.append( + ensure_scene_quality_contract( + { + "scene_id": scene_id, + "scene_function": scene_function, + "phase_support": ["setup", "early_rising", "midpoint", "late_turn"], + "required_roles": role_pair, + "beats_template": list(beats), + } + ) + ) + existing_ids.add(scene_id) + existing_functions.add(scene_function) + existing_role_pairs.add(" / ".join(sorted(role_pair))) + + prioritized_templates = sorted( + LONGFORM_SCENE_TEMPLATE_CATALOG, + key=lambda item: ( + 0 if str(item[1]) not in existing_functions else 1, + item[1], + item[0], + ), + ) + for index, (scene_suffix, scene_function, beats) in enumerate(prioritized_templates, start=1): + if not needs_more(): + break + scene_id = f"{preset_id}_{scene_suffix}" + if scene_id in existing_ids: + continue + if len(existing_functions) < desired_scene_family_count and scene_function in existing_functions: + continue + append_scene(scene_id, scene_function, beats, index - 1) + fallback_scene_functions = [ + "setup", + "trust_test", + "discovery", + "reversal", + "temptation", + "false_peace", + "confession_window", + "truth_trial", + "debt_exchange", + "karma_ripening", + "misrecognition", + "humiliation", + "vow_payment", + "mercy_vs_control", + ] + extra_index = 1 + while needs_more(): + scene_id = f"{preset_id}_extended_scene_{extra_index}" + if scene_id not in existing_ids: + missing_fallback = [item for item in fallback_scene_functions if item not in existing_functions] + scene_function = missing_fallback[0] if missing_fallback else fallback_scene_functions[(extra_index - 1) % len(fallback_scene_functions)] + append_scene(scene_id, scene_function, ["重回旧地", "细节露口", "关系偏转", "留下更大的追问"], extra_index - 1) + extra_index += 1 + return next_scenes + + +def _series_promise_templates(*, series_id: str, world_title: str, life_theme: str) -> List[Dict[str, Any]]: + return [ + { + "promise_id": f"{series_id}::promise_core_truth", + "label": f"{world_title} 的核心真相迟早要被真正认下", + "holders": ["lead", "counterpart"], + "stakes": "high", + "due_by_chapter": 0, + "source_level": "series", + "description": f"围绕“{life_theme or world_title}”的核心真相,必须在长线中被真正承担。", + }, + { + "promise_id": f"{series_id}::promise_choice_cost", + "label": "每一次选择都要先有代价落到关系里", + "holders": ["lead", "counterpart"], + "stakes": "high", + "due_by_chapter": 0, + "source_level": "series", + "description": "长线里的重大推进必须伴随关系、局势或代价的重新分配。", + }, + { + "promise_id": f"{series_id}::promise_world_consequence", + "label": "世界层后果不会自动消失", + "holders": ["lead"], + "stakes": "medium", + "due_by_chapter": 0, + "source_level": "series", + "description": "外部异变、秩序反噬或系统代价必须持续回到主线中追账。", + }, + ] + + +def _volume_promise_templates(*, volume_id: str, volume_index: int, volume_target: int) -> List[Dict[str, Any]]: + midpoint_due = max(2, min(volume_target, max(2, volume_target // 2))) + return [ + { + "promise_id": f"{volume_id}::promise_main", + "label": f"第{volume_index}卷主冲突必须在卷内转向一次", + "holders": ["lead", "counterpart"], + "stakes": "high", + "due_by_chapter": midpoint_due, + "source_level": "volume", + "description": f"第{volume_index}卷里,主冲突必须显式转向,不能只靠解释拖住。", + }, + { + "promise_id": f"{volume_id}::promise_relationship", + "label": f"第{volume_index}卷关系债要被重新分配", + "holders": ["lead", "counterpart"], + "stakes": "medium", + "due_by_chapter": volume_target, + "source_level": "volume", + "description": f"第{volume_index}卷结束前,人物关系至少要被重新定义一次。", + }, + ] + + +def _arc_promise_template(*, arc_id: str, volume_index: int, arc_offset: int, arc_size: int) -> Dict[str, Any]: + return { + "promise_id": f"{arc_id}::promise_turn", + "label": f"第{volume_index}卷第{arc_offset}弧必须留下下一弧要追的账", + "holders": ["lead", "counterpart"], + "stakes": "medium", + "due_by_chapter": max(1, arc_size), + "source_level": "arc", + "description": f"这条弧线结束时不能收死,必须把后续追问继续推出去。", + } + + +def _chapter_task_promise_targets( + *, + duty_type: str, + series_promises: List[Dict[str, Any]], + volume_promises: List[Dict[str, Any]], + arc_promise: Dict[str, Any], +) -> List[str]: + series_ids = [str(item.get("promise_id") or "") for item in series_promises if str(item.get("promise_id") or "")] + volume_ids = [str(item.get("promise_id") or "") for item in volume_promises if str(item.get("promise_id") or "")] + arc_id = str(arc_promise.get("promise_id") or "") + if duty_type == "advance_plot": + return [value for value in [volume_ids[0] if volume_ids else "", series_ids[0] if series_ids else ""] if value] + if duty_type == "advance_relationship": + return [value for value in [arc_id, volume_ids[1] if len(volume_ids) > 1 else (volume_ids[0] if volume_ids else "")] if value] + if duty_type == "expand_world": + return [value for value in [series_ids[2] if len(series_ids) > 2 else (series_ids[0] if series_ids else ""), volume_ids[0] if volume_ids else ""] if value] + if duty_type == "resolve_promise": + return [value for value in [arc_id, volume_ids[0] if volume_ids else ""] if value] + if duty_type == "deliver_climax": + return [value for value in [arc_id, volume_ids[1] if len(volume_ids) > 1 else (volume_ids[0] if volume_ids else ""), series_ids[1] if len(series_ids) > 1 else ""] if value] + if duty_type == "pace_breath": + return [value for value in [arc_id] if value] + return [value for value in [arc_id, volume_ids[0] if volume_ids else ""] if value] + + +def _chapter_task_promise_actions(*, duty_type: str, is_final_task_in_arc: bool, is_final_task_in_series: bool) -> tuple[List[str], bool]: + if duty_type == "advance_plot": + return ["maintain_continuity", "open_follow_on_promise"], False + if duty_type == "advance_relationship": + return ["maintain_continuity", "open_follow_on_promise"], False + if duty_type == "expand_world": + return ["maintain_continuity", "open_follow_on_promise"], False + if duty_type == "resolve_promise": + return ["advance_payoff", "maintain_continuity"], False + if duty_type == "deliver_climax": + return ["close_arc_loop", "advance_payoff", "maintain_continuity"], False + if duty_type == "pace_breath": + return (["maintain_continuity"], not is_final_task_in_arc and not is_final_task_in_series) + return ["maintain_continuity"], False + + +def _build_longform_structure( + *, + world_id: str, + world_title: str, + life_theme: str, + target_total_chapters: int, + target_total_volumes: int, + target_word_count: int, +) -> Dict[str, Any]: + series_id = f"{world_id}::series" + series_promises = _series_promise_templates(series_id=series_id, world_title=world_title, life_theme=life_theme) + chapters_per_volume_base = max(1, target_total_chapters // target_total_volumes) + volume_plans: List[Dict[str, Any]] = [] + arc_plans: List[Dict[str, Any]] = [] + remaining_chapters = target_total_chapters + duty_cycle = ( + [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax", + ] + if target_total_chapters >= 100 + else [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + ] + ) + for volume_index in range(1, target_total_volumes + 1): + if volume_index == target_total_volumes: + volume_target = remaining_chapters + else: + volume_target = chapters_per_volume_base + remaining_chapters -= volume_target + volume_id = f"{series_id}::volume_{volume_index}" + volume_promises = _volume_promise_templates(volume_id=volume_id, volume_index=volume_index, volume_target=volume_target) + volume_plans.append( + { + "volume_id": volume_id, + "order": volume_index, + "title": f"第{volume_index}卷", + "goal": f"围绕“{life_theme or world_title}”推进第{volume_index}卷主线与关系变化。", + "target_chapters": volume_target, + "climax_definition": f"第{volume_index}卷高潮必须改变主角与世界的一项稳定关系。", + "end_state": f"第{volume_index}卷结束时留下可推进下一卷的新债与新选择。", + "volume_promises": volume_promises, + } + ) + first_arc = max(1, volume_target // 3) + second_arc = max(1, volume_target // 3) + third_arc = max(1, volume_target - first_arc - second_arc) + arc_sizes = [first_arc, second_arc, third_arc] + for arc_offset, arc_size in enumerate(arc_sizes, start=1): + arc_id = f"{volume_id}::arc_{arc_offset}" + arc_promise = _arc_promise_template(arc_id=arc_id, volume_index=volume_index, arc_offset=arc_offset, arc_size=arc_size) + local_cycle = duty_cycle[arc_offset - 1 :] + duty_cycle[: arc_offset - 1] + chapter_tasks = [] + for task_index in range(1, arc_size + 1): + duty_type = local_cycle[(task_index - 1) % len(local_cycle)] + if task_index == arc_size and volume_index == target_total_volumes and arc_offset == len(arc_sizes): + duty_type = "deliver_climax" + elif task_index == arc_size: + duty_type = "resolve_promise" + elif task_index == max(1, arc_size - 1): + duty_type = "expand_world" if duty_type == "resolve_promise" else duty_type + promise_targets = _chapter_task_promise_targets( + duty_type=duty_type, + series_promises=series_promises, + volume_promises=volume_promises, + arc_promise=arc_promise, + ) + promise_actions, bridge_only = _chapter_task_promise_actions( + duty_type=duty_type, + is_final_task_in_arc=task_index == arc_size, + is_final_task_in_series=task_index == arc_size and volume_index == target_total_volumes and arc_offset == len(arc_sizes), + ) + chapter_tasks.append( + ensure_chapter_task_quality_contract( + { + "chapter_task_id": f"{arc_id}::task_{task_index}", + "objective": f"以 {duty_type} 为主职责推进当前弧线。", + "duty_type": duty_type, + "target_words": 2000, + "reveal_budget": 2 if duty_type in {"resolve_promise", "deliver_climax"} else 1, + "promise_actions": promise_actions, + "promise_targets": promise_targets, + "allow_terminal": duty_type == "deliver_climax" and volume_index == target_total_volumes and arc_offset == len(arc_sizes), + "bridge_only": bridge_only, + "notes": "auto_generated_longform_task", + }, + target_chapters=target_total_chapters, + ) + ) + arc_plans.append( + { + "arc_id": arc_id, + "volume_id": volume_id, + "order": arc_offset, + "title": f"第{volume_index}卷 · 第{arc_offset}弧", + "goal": f"完成第{volume_index}卷第{arc_offset}弧的核心推进。", + "conflict": f"让人物在第{volume_index}卷里承担新的关系与代价冲突。", + "reveal_budget": 2, + "payoff_targets": [f"{volume_id}::promise", f"{arc_id}::turn"], + "completion_conditions": ["main_conflict_shifted", "new_debt_or_promise_opened"], + "target_chapters": arc_size, + "arc_promises": [arc_promise], + "chapter_tasks": chapter_tasks, + } + ) + return { + "series_plan": { + "series_id": series_id, + "title": world_title, + "total_volume_target": target_total_volumes, + "total_chapter_target": target_total_chapters, + "target_word_count": target_word_count, + "theme_statement": life_theme or world_title, + "series_promises": series_promises, + }, + "volume_plans": volume_plans, + "arc_plans": arc_plans, + "chapter_budget_policy": { + "default_target_words": 2000, + "min_target_words": 1800, + "max_target_words": 2200, + "default_reveal_budget": 1, + "duty_cycle": duty_cycle, + }, + } + + def _build_characters_for_preset( *, preset_id: str, @@ -1650,13 +7612,15 @@ def _build_scene_blueprints_for_preset(preset_id: str) -> List[Dict[str, Any]]: ], } return [ - { - "scene_id": scene_id, - "scene_function": scene_function, - "phase_support": ["setup", "early_rising", "midpoint"], - "required_roles": required_roles, - "beats_template": beats, - } + ensure_scene_quality_contract( + { + "scene_id": scene_id, + "scene_function": scene_function, + "phase_support": ["setup", "early_rising", "midpoint"], + "required_roles": required_roles, + "beats_template": beats, + } + ) for scene_id, scene_function, required_roles, beats in variants[preset_id] ] diff --git a/src/narrativeos/services/longform_capability.py b/src/narrativeos/services/longform_capability.py new file mode 100644 index 0000000..e8d9138 --- /dev/null +++ b/src/narrativeos/services/longform_capability.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional + +from ..persistence.repositories import SQLAlchemyPlatformRepository +from ..worldpacks.models import WorldVersion + + +LONGFORM_CAPABILITY_BAND_ORDER = ("100", "250", "500", "1000") +DEFAULT_LONGFORM_CAPABILITY_PROFILES = { + "quick_brief_max_target_chapters": 100, + "structured_longform_bands": ["250", "500", "1000"], + "bands": { + "100": {"min_characters": 8, "min_scene_blueprints": 8, "min_locations": 6, "min_scene_family_count": 6, "min_distinct_role_pairs": 6}, + "250": {"min_characters": 12, "min_scene_blueprints": 12, "min_locations": 8, "min_scene_family_count": 8, "min_distinct_role_pairs": 8}, + "500": {"min_characters": 16, "min_scene_blueprints": 16, "min_locations": 12, "min_scene_family_count": 10, "min_distinct_role_pairs": 10}, + "1000": {"min_characters": 24, "min_scene_blueprints": 24, "min_locations": 16, "min_scene_family_count": 12, "min_distinct_role_pairs": 12}, + }, +} + + +def load_longform_capability_profiles(base_dir: Path) -> Dict[str, Any]: + config_path = base_dir / "configs" / "longform_capability_profiles.json" + payload: Dict[str, Any] = json.loads(json.dumps(DEFAULT_LONGFORM_CAPABILITY_PROFILES)) + if config_path.exists(): + try: + file_payload = json.loads(config_path.read_text(encoding="utf-8")) + if isinstance(file_payload, dict): + payload.update({key: value for key, value in file_payload.items() if key != "bands"}) + if isinstance(file_payload.get("bands"), dict): + payload["bands"] = { + str(key): { + "min_characters": int(dict(value or {}).get("min_characters", 0) or 0), + "min_scene_blueprints": int(dict(value or {}).get("min_scene_blueprints", 0) or 0), + "min_locations": int(dict(value or {}).get("min_locations", 0) or 0), + "min_scene_family_count": int(dict(value or {}).get("min_scene_family_count", 0) or 0), + "min_distinct_role_pairs": int(dict(value or {}).get("min_distinct_role_pairs", 0) or 0), + } + for key, value in dict(file_payload.get("bands") or {}).items() + } + except Exception: + payload = json.loads(json.dumps(DEFAULT_LONGFORM_CAPABILITY_PROFILES)) + return payload + + +def target_band_for_chapters(target_total_chapters: int) -> str: + normalized = max(1, int(target_total_chapters or 0)) + if normalized >= 1000: + return "1000" + if normalized >= 500: + return "500" + if normalized >= 250: + return "250" + return "100" + + +def band_rank(band: Optional[str]) -> int: + normalized = str(band or "").strip() + if normalized not in LONGFORM_CAPABILITY_BAND_ORDER: + return -1 + return LONGFORM_CAPABILITY_BAND_ORDER.index(normalized) + + +def band_minimums(profiles: Dict[str, Any], band: str) -> Dict[str, int]: + bands = dict(profiles.get("bands") or {}) + defaults = dict(DEFAULT_LONGFORM_CAPABILITY_PROFILES["bands"]) + source = dict(bands.get(str(band), {}) or defaults.get(str(band), {}) or {}) + return { + "min_characters": int(source.get("min_characters", 0) or 0), + "min_scene_blueprints": int(source.get("min_scene_blueprints", 0) or 0), + "min_locations": int(source.get("min_locations", 0) or 0), + "min_scene_family_count": int(source.get("min_scene_family_count", 0) or 0), + "min_distinct_role_pairs": int(source.get("min_distinct_role_pairs", 0) or 0), + } + + +def quick_brief_max_target_chapters(profiles: Dict[str, Any]) -> int: + return max(100, int(profiles.get("quick_brief_max_target_chapters", 100) or 100)) + + +def longform_entry_mode(metadata: Dict[str, Any]) -> str: + stored = str(metadata.get("entry_mode") or "").strip() + if stored: + return stored + if metadata.get("generated_from_brief"): + return "quick_brief" + return "structured_longform" + + +def longform_structure_counts(worldpack_payload: Dict[str, Any]) -> Dict[str, int]: + scene_functions = { + str((item or {}).get("scene_function") or "").strip() + for item in list(worldpack_payload.get("scene_blueprints") or []) + if str((item or {}).get("scene_function") or "").strip() + } + role_pairs = { + " / ".join(sorted([str(role).strip() for role in list((item or {}).get("required_roles") or []) if str(role).strip()])) + for item in list(worldpack_payload.get("scene_blueprints") or []) + if len([str(role).strip() for role in list((item or {}).get("required_roles") or []) if str(role).strip()]) >= 2 + } + return { + "character_count": len(list(worldpack_payload.get("characters") or [])), + "scene_blueprint_count": len(list(worldpack_payload.get("scene_blueprints") or [])), + "location_count": len(list((worldpack_payload.get("world_bible") or {}).get("locations") or [])), + "scene_family_count": len(scene_functions), + "distinct_role_pair_count": len(role_pairs), + } + + +def supported_target_band(*, counts: Dict[str, int], entry_mode: str, profiles: Dict[str, Any]) -> Optional[str]: + highest: Optional[str] = None + for band in LONGFORM_CAPABILITY_BAND_ORDER: + minimums = band_minimums(profiles, band) + if ( + counts["character_count"] >= minimums["min_characters"] + and counts["scene_blueprint_count"] >= minimums["min_scene_blueprints"] + and counts["location_count"] >= minimums["min_locations"] + and counts["scene_family_count"] >= minimums["min_scene_family_count"] + and counts["distinct_role_pair_count"] >= minimums["min_distinct_role_pairs"] + ): + highest = band + if highest is None: + return None + if entry_mode == "quick_brief" and band_rank(highest) > band_rank("100"): + return "100" + return highest + + +def extract_open_promises_from_issue(issue: Dict[str, Any]) -> Optional[int]: + for evidence in list(issue.get("evidence") or []): + text = str(evidence or "") + if text.startswith("open_promises="): + try: + return int(text.split("=", 1)[1]) + except ValueError: + return None + return None + + +def latest_longform_runway_guard( + repository: SQLAlchemyPlatformRepository, + version: WorldVersion, + *, + target_total_chapters: int, +) -> Optional[Dict[str, Any]]: + if target_total_chapters < 100: + return None + works = repository.list_author_works(account_id=version.author_id, world_version_id=version.world_version_id, limit=20) + if not works: + return None + active_work = next((item for item in works if item.get("is_active_line")), None) or works[0] + revisions = repository.list_author_work_revisions(work_id=active_work["work_id"], limit=20) + blocked_revision = next((item for item in revisions if item.get("revision_type") == "quality_guard_blocked"), None) + if not blocked_revision: + return None + snapshot = dict(blocked_revision.get("snapshot_json") or {}) + quality_gate = dict(snapshot.get("quality_gate") or {}) + issues = [dict(item or {}) for item in list(quality_gate.get("issues") or [])] + issue_codes = {str(item.get("issue_code") or "").strip() for item in issues if str(item.get("issue_code") or "").strip()} + if "Q09" not in issue_codes: + return None + chapter_index = int(snapshot.get("chapter_index") or 0) + if chapter_index <= 0 or chapter_index >= int(target_total_chapters * 0.8): + return None + open_promises = None + for issue in issues: + open_promises = extract_open_promises_from_issue(issue) + if open_promises is not None: + break + if open_promises is None or open_promises > 0: + return None + return { + "key": "longform_structure_exhaustion", + "severity": "high", + "message": f"当前长线在第 {chapter_index} 章附近出现续航耗空信号:开放 promises 已归零,继续盲跑更容易触发节奏塌陷。", + "chapter_index": chapter_index, + "work_id": active_work.get("work_id"), + "pacing": dict(quality_gate.get("scores") or {}).get("pacing"), + "issue_codes": sorted(issue_codes), + "recommended_actions": [ + "bootstrap_structured_longform", + "expand_character_and_scene_lattice", + "rebuild_promise_lattice", + ], + } + + +def build_longform_500_product_readiness( + *, + claim_safe_band: Optional[str], + longform_readiness: Dict[str, Any], + simulation_report: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + claim_band = str(claim_safe_band or "").strip() + structural_status = str(dict(longform_readiness or {}).get("status") or "") + report = dict(simulation_report or {}) + cross_pack = dict(report.get("cross_pack_summary") or report) + signoff = dict(cross_pack.get("longform_500_signoff") or {}) + interactive_signoff = dict(cross_pack.get("longform_500_interactive_signoff") or {}) + human_closeout = dict(cross_pack.get("longform_500_human_review_closeout") or {}) + review_sample_coverage = dict(cross_pack.get("review_sample_coverage_500") or {}) + weakest_program = dict(cross_pack.get("weakest_pack_polish_program") or {}) + hard_summary = dict(cross_pack.get("generation_hard_constraint_summary") or {}) + scene_card_audit = dict(hard_summary.get("scene_card_visible_text_audit") or {}) + runtime_profile = dict(cross_pack.get("benchmark_runtime_profile") or {}) + replay_projection = dict(cross_pack.get("reader_replay_projection_summary") or {}) + reader_storybook = dict(cross_pack.get("reader_storybook_500_verification") or {}) + + blockers: List[Dict[str, Any]] = [] + if not claim_band or band_rank(claim_band) < band_rank("500"): + blockers.append({"key": "longform_500_structure_not_claimed", "severity": "high"}) + if structural_status != "ready": + blockers.append({"key": "longform_structure_not_ready", "severity": "high", "status": structural_status}) + if not bool(signoff.get("ready", False)): + blockers.append({"key": "longform_500_static_signoff_missing", "severity": "high"}) + if not bool(interactive_signoff.get("ready", False)): + blockers.append({"key": "longform_500_interactive_signoff_missing", "severity": "medium"}) + human_closeout_ready = bool(human_closeout.get("ready", False) or review_sample_coverage.get("human_closeout_ready", False)) + if not human_closeout_ready: + blockers.append({"key": "longform_500_human_review_closeout_missing", "severity": "high"}) + if not weakest_program or str(weakest_program.get("status") or "") != "stop_ready": + blockers.append( + { + "key": "longform_500_weakest_stop_ready_missing", + "severity": "high", + "status": str(weakest_program.get("status") or "missing"), + "continue_worlds": list(weakest_program.get("continue_worlds") or []), + } + ) + if int(hard_summary.get("chapter_count", 0) or 0) < 500 or int(hard_summary.get("hard_fail_count", 0) or 0) > 0: + blockers.append( + { + "key": "longform_500_hard_constraint_evidence_missing", + "severity": "high", + "chapter_count": int(hard_summary.get("chapter_count", 0) or 0), + "hard_fail_count": int(hard_summary.get("hard_fail_count", 0) or 0), + } + ) + scene_card_violation_count = int(scene_card_audit.get("violation_count", 0) or 0) + if "scene_card_visible_text_audit" not in hard_summary or scene_card_violation_count > 0: + blockers.append( + { + "key": "longform_500_scene_card_visible_text_evidence_missing", + "severity": "high", + "violation_count": scene_card_violation_count, + } + ) + replay_ready = bool(replay_projection.get("ready", False) or reader_storybook.get("ready", False)) + if not replay_ready: + blockers.append({"key": "reader_500_replay_projection_evidence_missing", "severity": "medium"}) + if not runtime_profile: + blockers.append({"key": "longform_500_runtime_profile_missing", "severity": "medium"}) + + ready = not blockers + return { + "schema_version": "longform_500_product_readiness/v1", + "target_band": "500", + "status": "ready" if ready else "watch", + "ready": ready, + "claim_safe_band": claim_band or None, + "product_ready_band": "500" if ready else None, + "blockers": blockers, + "evidence": { + "static_signoff_ready": bool(signoff.get("ready", False)), + "interactive_signoff_ready": bool(interactive_signoff.get("ready", False)), + "human_closeout_ready": human_closeout_ready, + "weakest_pack_stop_ready": str(weakest_program.get("status") or "") == "stop_ready", + "weakest_pack_continue_worlds": list(weakest_program.get("continue_worlds") or []), + "hard_constraint_chapter_count": int(hard_summary.get("chapter_count", 0) or 0), + "hard_constraint_fail_count": int(hard_summary.get("hard_fail_count", 0) or 0), + "scene_card_visible_text_audit_present": "scene_card_visible_text_audit" in hard_summary, + "scene_card_visible_text_violation_count": scene_card_violation_count, + "reader_replay_projection_ready": replay_ready, + "runtime_profile_present": bool(runtime_profile), + }, + "recommended_actions": [] if ready else ["run_longform_500_product_readiness_bundle"], + } + + +def build_longform_capability_payload( + *, + base_dir: Path, + repository: SQLAlchemyPlatformRepository, + worldpack_payload: Dict[str, Any], + version: Optional[WorldVersion] = None, +) -> Dict[str, Any]: + profiles = load_longform_capability_profiles(base_dir) + metadata = dict(worldpack_payload.get("metadata") or {}) + brief = dict(metadata.get("author_brief") or {}) + requested_target_chapters = max( + 1, + int( + metadata.get("requested_target_chapters") + or brief.get("target_total_chapters") + or ((worldpack_payload.get("series_plan") or {}).get("total_chapter_target") or 100) + ), + ) + requested_band = target_band_for_chapters(requested_target_chapters) + entry_mode_value = longform_entry_mode(metadata) + counts = longform_structure_counts(worldpack_payload) + supported_band_value = supported_target_band(counts=counts, entry_mode=entry_mode_value, profiles=profiles) + requires_structured = ( + requested_target_chapters > quick_brief_max_target_chapters(profiles) + and entry_mode_value != "structured_longform" + ) + readiness_status = "ready" + blockers: List[Dict[str, Any]] = [] + minimums = band_minimums(profiles, requested_band) + if requires_structured: + readiness_status = "blocked" + blockers.append( + { + "key": "structured_longform_required", + "severity": "high", + "message": f"当前入口是 quick brief,只能直接承诺到 {quick_brief_max_target_chapters(profiles)} 章;若要继续走 {requested_band} 章,需要先进入结构化长篇蓝图。", + } + ) + deficits = [] + if counts["character_count"] < minimums["min_characters"]: + deficits.append(f"角色 {counts['character_count']}/{minimums['min_characters']}") + if counts["scene_blueprint_count"] < minimums["min_scene_blueprints"]: + deficits.append(f"场景 {counts['scene_blueprint_count']}/{minimums['min_scene_blueprints']}") + if counts["location_count"] < minimums["min_locations"]: + deficits.append(f"地点 {counts['location_count']}/{minimums['min_locations']}") + if counts["scene_family_count"] < minimums["min_scene_family_count"]: + deficits.append(f"scene family {counts['scene_family_count']}/{minimums['min_scene_family_count']}") + if counts["distinct_role_pair_count"] < minimums["min_distinct_role_pairs"]: + deficits.append(f"role pairs {counts['distinct_role_pair_count']}/{minimums['min_distinct_role_pairs']}") + if deficits: + if readiness_status == "ready": + readiness_status = "needs_enrichment" + blockers.append( + { + "key": "longform_structure_minimums", + "severity": "high" if requested_band != "100" else "medium", + "message": f"{requested_band} 章能力的最小骨架还不够:{' / '.join(deficits)}。", + } + ) + runway_guard = latest_longform_runway_guard(repository, version, target_total_chapters=requested_target_chapters) if version is not None else None + if runway_guard: + readiness_status = "blocked" + blockers.append(runway_guard) + recommended_actions: List[str] = [] + if requires_structured: + recommended_actions.append("bootstrap_structured_longform") + elif deficits: + recommended_actions.append("expand_longform_structure") + if runway_guard: + recommended_actions.append("repair_longform_runway") + if not recommended_actions: + recommended_actions.append("continue_authoring") + longform_readiness = { + "band": requested_band, + "status": readiness_status, + "blockers": blockers, + "recommended_actions": recommended_actions, + "minimums": minimums, + } + product_readiness_500 = build_longform_500_product_readiness( + claim_safe_band=supported_band_value, + longform_readiness=longform_readiness, + simulation_report=dict(getattr(version, "simulation_report_json", {}) or {}) if version is not None else {}, + ) + return { + "entry_mode": entry_mode_value, + "requested_target_chapters": requested_target_chapters, + "requested_target_band": requested_band, + "supported_target_band": supported_band_value, + "claim_safe_band": supported_band_value, + "product_ready_band": product_readiness_500.get("product_ready_band"), + "requires_structured_longform": requires_structured, + "structure_counts": counts, + "longform_readiness": longform_readiness, + "longform_500_product_readiness": product_readiness_500, + } + + +def sync_longform_capability_metadata( + *, + base_dir: Path, + repository: SQLAlchemyPlatformRepository, + worldpack_payload: Dict[str, Any], + version: Optional[WorldVersion] = None, +) -> Dict[str, Any]: + metadata = dict(worldpack_payload.get("metadata") or {}) + capability = build_longform_capability_payload( + base_dir=base_dir, + repository=repository, + worldpack_payload=worldpack_payload, + version=version, + ) + metadata["requested_target_chapters"] = capability["requested_target_chapters"] + metadata["entry_mode"] = capability["entry_mode"] + metadata["capability_band_supported"] = capability["supported_target_band"] + metadata["requires_structured_longform"] = capability["requires_structured_longform"] + metadata["claim_safe_band"] = capability["claim_safe_band"] + metadata["product_ready_band"] = capability["product_ready_band"] + metadata["longform_readiness"] = dict(capability["longform_readiness"]) + metadata["longform_500_product_readiness"] = dict(capability["longform_500_product_readiness"]) + worldpack_payload["metadata"] = metadata + return capability diff --git a/src/narrativeos/services/reader_storybook_title_homogenization.py b/src/narrativeos/services/reader_storybook_title_homogenization.py new file mode 100644 index 0000000..2347eb2 --- /dev/null +++ b/src/narrativeos/services/reader_storybook_title_homogenization.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + + +READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_SCHEMA_VERSION = ( + "reader_storybook_title_homogenization_history/v1" +) +READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_FILENAME = ( + "reader_storybook_long_route_smoke_history.json" +) +READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_LIMIT = 20 +READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD = 3 +TITLE_HOMOGENIZATION_WARNING_KIND = "title_homogenization_non_blocking" + + +def reader_storybook_title_homogenization_history_path(base_dir: Path) -> Path: + return Path(base_dir) / "artifacts" / READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_FILENAME + + +def _safe_float(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _pair_key(non_jade_world_id: str, jade_world_id: str) -> Tuple[str, str]: + return (str(non_jade_world_id or "").strip(), str(jade_world_id or "").strip()) + + +def _normalize_world_ids(items: Any) -> List[str]: + normalized: List[str] = [] + for item in list(items or []): + candidate = str(item or "").strip() + if candidate and candidate not in normalized: + normalized.append(candidate) + return normalized + + +def _normalize_cross_pack_distinctness_item(item: Dict[str, Any]) -> Dict[str, Any]: + return { + "non_jade_world_id": str(item.get("non_jade_world_id") or "").strip(), + "jade_world_id": str(item.get("jade_world_id") or "").strip(), + "title_similarity": round(_safe_float(item.get("title_similarity")), 3), + "quote_similarity": round(_safe_float(item.get("quote_similarity")), 3), + "passes_min_difference": bool(item.get("passes_min_difference", False)), + } + + +def _normalize_warning_item(item: Dict[str, Any]) -> Dict[str, Any]: + return { + "non_jade_world_id": str(item.get("non_jade_world_id") or "").strip(), + "jade_world_id": str(item.get("jade_world_id") or "").strip(), + "title_similarity": round(_safe_float(item.get("title_similarity")), 3), + "quote_similarity": round(_safe_float(item.get("quote_similarity")), 3), + "warning_kind": str(item.get("warning_kind") or TITLE_HOMOGENIZATION_WARNING_KIND).strip(), + "message": str(item.get("message") or "").strip(), + } + + +def build_reader_storybook_title_homogenization_history_entry( + *, + generated_at: str, + world_ids: List[str], + cross_pack_distinctness: List[Dict[str, Any]], + title_homogenization_warnings: List[Dict[str, Any]], +) -> Dict[str, Any]: + return { + "generated_at": str(generated_at or "").strip(), + "world_ids": _normalize_world_ids(world_ids), + "cross_pack_distinctness": [ + normalized + for normalized in ( + _normalize_cross_pack_distinctness_item(dict(item or {})) + for item in list(cross_pack_distinctness or []) + ) + if normalized["non_jade_world_id"] and normalized["jade_world_id"] + ], + "title_homogenization_warnings": [ + normalized + for normalized in ( + _normalize_warning_item(dict(item or {})) + for item in list(title_homogenization_warnings or []) + ) + if normalized["non_jade_world_id"] and normalized["jade_world_id"] + ], + } + + +def normalize_reader_storybook_title_homogenization_history( + payload: Optional[Dict[str, Any]], + *, + history_limit: int = READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_LIMIT, + promotion_threshold: int = READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD, +) -> Dict[str, Any]: + entries = [] + for item in list(dict(payload or {}).get("entries") or []): + normalized = build_reader_storybook_title_homogenization_history_entry( + generated_at=str(dict(item or {}).get("generated_at") or "").strip(), + world_ids=list(dict(item or {}).get("world_ids") or []), + cross_pack_distinctness=list(dict(item or {}).get("cross_pack_distinctness") or []), + title_homogenization_warnings=list( + dict(item or {}).get("title_homogenization_warnings") or [] + ), + ) + if normalized["generated_at"]: + entries.append(normalized) + entries.sort(key=lambda item: item["generated_at"], reverse=True) + limit = max(1, int(history_limit or READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_LIMIT)) + entries = entries[:limit] + return { + "schema_version": READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_SCHEMA_VERSION, + "available": bool(entries), + "history_limit": limit, + "promotion_threshold": max( + 1, int(promotion_threshold or READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD) + ), + "entry_count": len(entries), + "latest_generated_at": entries[0]["generated_at"] if entries else None, + "entries": entries, + } + + +def append_reader_storybook_title_homogenization_history_entry( + history_payload: Optional[Dict[str, Any]], + entry: Dict[str, Any], + *, + history_limit: int = READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_LIMIT, + promotion_threshold: int = READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD, +) -> Dict[str, Any]: + normalized = normalize_reader_storybook_title_homogenization_history( + history_payload, + history_limit=history_limit, + promotion_threshold=promotion_threshold, + ) + new_entry = build_reader_storybook_title_homogenization_history_entry( + generated_at=str(entry.get("generated_at") or "").strip(), + world_ids=list(entry.get("world_ids") or []), + cross_pack_distinctness=list(entry.get("cross_pack_distinctness") or []), + title_homogenization_warnings=list(entry.get("title_homogenization_warnings") or []), + ) + entries = [new_entry, *list(normalized.get("entries") or [])] if new_entry["generated_at"] else list( + normalized.get("entries") or [] + ) + return normalize_reader_storybook_title_homogenization_history( + {"entries": entries}, + history_limit=history_limit, + promotion_threshold=promotion_threshold, + ) + + +def load_reader_storybook_title_homogenization_history( + *, + path: Optional[Path] = None, + base_dir: Optional[Path] = None, + history_limit: int = READER_STORYBOOK_TITLE_HOMOGENIZATION_HISTORY_LIMIT, + promotion_threshold: int = READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD, +) -> Dict[str, Any]: + resolved_path = path or reader_storybook_title_homogenization_history_path( + base_dir or Path(__file__).resolve().parents[3] + ) + if not resolved_path.exists(): + return normalize_reader_storybook_title_homogenization_history( + {}, + history_limit=history_limit, + promotion_threshold=promotion_threshold, + ) + try: + payload = json.loads(resolved_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + payload = {} + return normalize_reader_storybook_title_homogenization_history( + payload, + history_limit=history_limit, + promotion_threshold=promotion_threshold, + ) + + +def _entry_includes_pair(entry: Dict[str, Any], pair: Tuple[str, str]) -> bool: + world_ids = set(_normalize_world_ids(entry.get("world_ids") or [])) + return pair[0] in world_ids and pair[1] in world_ids + + +def _warning_for_pair(entry: Dict[str, Any], pair: Tuple[str, str]) -> Optional[Dict[str, Any]]: + for item in list(entry.get("title_homogenization_warnings") or []): + if _pair_key(item.get("non_jade_world_id"), item.get("jade_world_id")) == pair: + return dict(item) + return None + + +def _comparison_for_pair(entry: Dict[str, Any], pair: Tuple[str, str]) -> Optional[Dict[str, Any]]: + for item in list(entry.get("cross_pack_distinctness") or []): + if _pair_key(item.get("non_jade_world_id"), item.get("jade_world_id")) == pair: + return dict(item) + return None + + +def build_reader_storybook_title_homogenization_trend( + history_payload: Optional[Dict[str, Any]], + *, + promotion_threshold: int = READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD, +) -> Dict[str, Any]: + normalized_history = normalize_reader_storybook_title_homogenization_history( + history_payload, + promotion_threshold=promotion_threshold, + ) + entries = list(normalized_history.get("entries") or []) + pair_keys = set() + for entry in entries: + for item in list(entry.get("cross_pack_distinctness") or []): + pair_keys.add(_pair_key(item.get("non_jade_world_id"), item.get("jade_world_id"))) + for item in list(entry.get("title_homogenization_warnings") or []): + pair_keys.add(_pair_key(item.get("non_jade_world_id"), item.get("jade_world_id"))) + + pair_trends = [] + threshold = max( + 1, int(promotion_threshold or normalized_history.get("promotion_threshold") or READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD) + ) + for non_jade_world_id, jade_world_id in sorted(pair_keys): + consecutive_warning_count = 0 + eligible_run_count = 0 + latest_warning: Optional[Dict[str, Any]] = None + latest_warning_at: Optional[str] = None + latest_comparison: Optional[Dict[str, Any]] = None + latest_comparison_at: Optional[str] = None + for entry in entries: + comparison = _comparison_for_pair(entry, (non_jade_world_id, jade_world_id)) + if comparison and latest_comparison is None: + latest_comparison = comparison + latest_comparison_at = str(entry.get("generated_at") or "") + if not _entry_includes_pair(entry, (non_jade_world_id, jade_world_id)): + continue + eligible_run_count += 1 + warning = _warning_for_pair(entry, (non_jade_world_id, jade_world_id)) + if warning: + consecutive_warning_count += 1 + if latest_warning is None: + latest_warning = warning + latest_warning_at = str(entry.get("generated_at") or "") + continue + break + promoted = consecutive_warning_count >= threshold and consecutive_warning_count > 0 + pair_trends.append( + { + "non_jade_world_id": non_jade_world_id, + "jade_world_id": jade_world_id, + "eligible_run_count": eligible_run_count, + "consecutive_warning_count": consecutive_warning_count, + "latest_seen_at": latest_warning_at, + "latest_warning_kind": ( + str(latest_warning.get("warning_kind") or "") if latest_warning else "" + ), + "latest_title_similarity": round( + _safe_float((latest_comparison or {}).get("title_similarity")), 3 + ), + "latest_quote_similarity": round( + _safe_float((latest_comparison or {}).get("quote_similarity")), 3 + ), + "trend_status": ( + "promoted" if promoted else ("watch" if consecutive_warning_count > 0 else "clear") + ), + "promoted_to_release_review": promoted, + } + ) + + promoted_pairs = [ + dict(item) + for item in pair_trends + if bool(item.get("promoted_to_release_review", False)) + ] + if not entries: + trend_status = "no_history" + trend_reason = "no_reader_storybook_smoke_history" + elif promoted_pairs: + trend_status = "promoted_pairs_present" + trend_reason = "title_homogenization_promotion_threshold_met" + elif any(int(item.get("consecutive_warning_count", 0) or 0) > 0 for item in pair_trends): + trend_status = "watch" + trend_reason = "title_homogenization_warning_streak_below_threshold" + else: + trend_status = "clear" + trend_reason = "no_active_title_homogenization_warning_streaks" + return { + "available": bool(entries), + "entry_count": int(normalized_history.get("entry_count", 0) or 0), + "latest_generated_at": normalized_history.get("latest_generated_at"), + "threshold": threshold, + "trend_status": trend_status, + "trend_reason": trend_reason, + "promoted_pair_count": len(promoted_pairs), + "promoted_pairs": promoted_pairs, + "pair_trends": pair_trends, + } + + +def summarize_reader_storybook_title_homogenization_history( + history_payload: Optional[Dict[str, Any]], + trend_payload: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + normalized_history = normalize_reader_storybook_title_homogenization_history(history_payload) + trend = dict( + trend_payload + or build_reader_storybook_title_homogenization_trend(normalized_history) + ) + return { + "available": bool(normalized_history.get("available", False)), + "entry_count": int(normalized_history.get("entry_count", 0) or 0), + "latest_generated_at": normalized_history.get("latest_generated_at"), + "threshold": int(trend.get("threshold", READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD) or READER_STORYBOOK_TITLE_HOMOGENIZATION_PROMOTION_THRESHOLD), + "trend_status": str(trend.get("trend_status") or ""), + "trend_reason": str(trend.get("trend_reason") or ""), + "promoted_pair_count": int(trend.get("promoted_pair_count", 0) or 0), + } + + +def promoted_reader_storybook_title_homogenization_pairs_for_world( + trend_payload: Optional[Dict[str, Any]], + *, + world_id: str, +) -> List[Dict[str, Any]]: + normalized_world_id = str(world_id or "").strip() + if not normalized_world_id: + return [] + return [ + dict(item) + for item in list(dict(trend_payload or {}).get("promoted_pairs") or []) + if str(item.get("non_jade_world_id") or "").strip() == normalized_world_id + ] diff --git a/src/narrativeos/services/training_signal.py b/src/narrativeos/services/training_signal.py index 35a4bbc..a9c3014 100644 --- a/src/narrativeos/services/training_signal.py +++ b/src/narrativeos/services/training_signal.py @@ -23,6 +23,16 @@ "rollback_performed", ] ABANDON_WINDOW_HOURS = 24 +LONGFORM_250_REVIEW_WINDOWS = ( + ("1-20", 1, 20), + ("80-120", 80, 120), + ("200-250", 200, 250), +) +LONGFORM_500_REVIEW_WINDOWS = ( + ("1-40", 1, 40), + ("220-300", 220, 300), + ("460-500", 460, 500), +) class TrainingSignalService: @@ -289,6 +299,186 @@ def _apply_incremental_window( ) return filtered[:limit] if limit is not None else filtered + def _chapter_index_from_id(self, chapter_id: Any) -> int: + suffix = str(chapter_id or "").rsplit("_", 1)[-1] + return int(suffix) if suffix.isdigit() else 0 + + def _longform_review_sampling_plan( + self, + report: Dict[str, Any], + *, + world_id: str, + world_version_id: str, + windows: Sequence[Tuple[str, int, int]], + reason_prefix: str, + default_issue_focus: Sequence[str], + ) -> List[Dict[str, Any]]: + chapter_ids = [ + str(item.get("chapter_id") or "") + for item in report.get("chapter_evaluations", []) + if isinstance(item, dict) + ] + available_indices = sorted( + { + self._chapter_index_from_id(chapter_id) + for chapter_id in chapter_ids + if self._chapter_index_from_id(chapter_id) > 0 + } + ) + max_index = max(available_indices or [0]) + top_issue_categories = list((report.get("evaluation_summary") or {}).get("top_issue_categories", [])) + issue_focus = [ + str(item.get("issue_code") or "") + for item in top_issue_categories[:2] + if isinstance(item, dict) and str(item.get("issue_code") or "") + ] + plan: List[Dict[str, Any]] = [] + for window_label, start, end in windows: + candidates = [index for index in available_indices if start <= index <= end] + if not candidates: + continue + picks = [candidates[0]] + if len(candidates) > 1: + picks.append(candidates[min(len(candidates) - 1, len(candidates) // 2)]) + seen: Set[int] = set() + for priority, chapter_index in enumerate(picks, start=1): + if chapter_index in seen: + continue + seen.add(chapter_index) + plan.append( + { + "world_id": world_id, + "world_version_id": world_version_id, + "window_label": window_label, + "chapter_index": chapter_index, + "issue_focus": issue_focus or list(default_issue_focus), + "priority": priority, + "reason": f"{reason_prefix}_window_{window_label}", + "available_chapter_max": max_index, + } + ) + return plan + + def _matching_human_review_samples_for_target( + self, + review_samples: Sequence[Dict[str, Any]], + *, + world_version_id: str, + chapter_index: int, + ) -> List[Dict[str, Any]]: + return [ + dict(sample) + for sample in review_samples + if str(sample.get("world_version_id") or "") == world_version_id + and ( + self._chapter_index_from_id(sample.get("chapter_id")) == int(chapter_index) + or self._chapter_index_from_id(dict(sample.get("source_ref") or {}).get("chapter_id")) == int(chapter_index) + ) + ] + + def _longform_human_review_closeout( + self, + *, + world_id: Optional[str], + world_version_id: Optional[str], + target_chapters: int, + summary_key: str, + windows: Sequence[Tuple[str, int, int]], + reason_prefix: str, + default_issue_focus: Sequence[str], + ending_window_label: Optional[str] = None, + ) -> Dict[str, Any]: + sampling_plan: List[Dict[str, Any]] = [] + for version_meta in self._selected_versions(world_id=world_id, world_version_id=world_version_id): + version = self.repository.get_world_version(version_meta["world_version_id"]) + report = dict(version.simulation_report_json or {}) + chapter_evaluations = report.get("chapter_evaluations") or [] + completed_chapters = int(report.get("completed_chapters") or 0) + if not chapter_evaluations: + continue + if summary_key not in report and completed_chapters < target_chapters: + continue + sampling_plan.extend( + self._longform_review_sampling_plan( + report, + world_id=version.world_id, + world_version_id=version.world_version_id, + windows=windows, + reason_prefix=reason_prefix, + default_issue_focus=default_issue_focus, + ) + ) + + review_samples = self.list_review_samples( + world_id=world_id, + world_version_id=world_version_id, + source="human_review", + limit=None, + ) + human_reviewed_targets: List[Dict[str, Any]] = [] + backlog: List[Dict[str, Any]] = [] + window_coverage: Dict[str, Dict[str, int]] = { + label: {"target_count": 0, "human_reviewed_count": 0} + for label, _start, _end in windows + } + ending_window_target_count = 0 + ending_window_human_reviewed_count = 0 + + for target in sampling_plan: + window_label = str(target.get("window_label") or "") + chapter_index = int(target.get("chapter_index") or 0) + bucket = window_coverage.setdefault(window_label, {"target_count": 0, "human_reviewed_count": 0}) + bucket["target_count"] += 1 + if ending_window_label and window_label == ending_window_label: + ending_window_target_count += 1 + matching = self._matching_human_review_samples_for_target( + review_samples, + world_version_id=str(target.get("world_version_id") or ""), + chapter_index=chapter_index, + ) + if matching: + bucket["human_reviewed_count"] += 1 + human_reviewed_targets.append(dict(target)) + if ending_window_label and window_label == ending_window_label: + ending_window_human_reviewed_count += 1 + else: + backlog.append( + { + **dict(target), + "status": "needs_human_review", + } + ) + + planned_target_count = len(sampling_plan) + human_reviewed_target_count = len(human_reviewed_targets) + human_closeout_ready = planned_target_count > 0 and human_reviewed_target_count >= planned_target_count + summary = { + "target_chapters": target_chapters, + "window_labels": [label for label, _start, _end in windows], + "planned_target_count": planned_target_count, + "human_reviewed_target_count": human_reviewed_target_count, + "human_reviewed_world_count": len({str(item.get("world_id") or "") for item in human_reviewed_targets}), + "human_closeout_ready": human_closeout_ready, + "human_closeout_status": "closed" if human_closeout_ready else ("partial" if human_reviewed_targets else "watch"), + "window_coverage": window_coverage, + "backlog": backlog, + "human_unreviewed_targets": backlog, + "sampling_plan": sampling_plan, + } + if ending_window_label: + summary.update( + { + "ending_window_label": ending_window_label, + "ending_window_target_count": ending_window_target_count, + "ending_window_human_reviewed_count": ending_window_human_reviewed_count, + "ending_window_human_closeout_ready": ( + ending_window_target_count > 0 + and ending_window_human_reviewed_count >= ending_window_target_count + ), + } + ) + return summary + def _review_sample_from_report(self, report_payload: Dict[str, Any], *, world_id: str) -> Dict[str, Any]: report = EvaluationReport.from_dict(report_payload) sample = { @@ -368,6 +558,20 @@ def save_review_sample(self, payload: Dict[str, Any]) -> Dict[str, Any]: ) return sample + def save_review_sample_from_report(self, report_payload: Dict[str, Any], *, world_id: str) -> Dict[str, Any]: + sample = self._review_sample_from_report(report_payload, world_id=world_id) + self.repository.save_review_record( + { + "review_id": "review_sample_%s" % sample["sample_id"], + "asset_type": "review_sample", + "asset_id": sample["chapter_id"], + "status": sample["source"], + "reviewer_id": sample["reviewer_id"], + "notes": json.dumps(sample, ensure_ascii=False), + } + ) + return sample + def list_review_samples( self, *, @@ -416,6 +620,39 @@ def list_review_samples( limit=limit, ) + def longform_250_human_review_closeout( + self, + *, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + ) -> Dict[str, Any]: + return self._longform_human_review_closeout( + world_id=world_id, + world_version_id=world_version_id, + target_chapters=250, + summary_key="longform_250_summary", + windows=LONGFORM_250_REVIEW_WINDOWS, + reason_prefix="longform_250", + default_issue_focus=["Q03", "Q05", "Q09"], + ) + + def longform_500_human_review_closeout( + self, + *, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + ) -> Dict[str, Any]: + return self._longform_human_review_closeout( + world_id=world_id, + world_version_id=world_version_id, + target_chapters=500, + summary_key="longform_500_summary", + windows=LONGFORM_500_REVIEW_WINDOWS, + reason_prefix="longform_500", + default_issue_focus=["Q03", "Q05", "Q09"], + ending_window_label=LONGFORM_500_REVIEW_WINDOWS[-1][0], + ) + def save_preference_sample(self, payload: Dict[str, Any]) -> Dict[str, Any]: source = str(payload.get("source") or "human_preference") if source != "human_preference": diff --git a/src/narrativeos/worldpacks/models.py b/src/narrativeos/worldpacks/models.py index e1fadf7..7bfd5a9 100644 --- a/src/narrativeos/worldpacks/models.py +++ b/src/narrativeos/worldpacks/models.py @@ -7,6 +7,200 @@ from ..models import CharacterState, EventAtom, NarrativeState, WorldBible, WorldRecord +@dataclass +class SeriesPlan: + series_id: str + title: str + total_volume_target: int + total_chapter_target: int + target_word_count: int + theme_statement: str = "" + series_promises: List[Dict[str, Any]] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "SeriesPlan": + return cls( + series_id=str(data["series_id"]), + title=str(data["title"]), + total_volume_target=int(data.get("total_volume_target", 1)), + total_chapter_target=int(data.get("total_chapter_target", 1)), + target_word_count=int(data.get("target_word_count", 1000)), + theme_statement=str(data.get("theme_statement", "")), + series_promises=[dict(item) for item in data.get("series_promises", [])], + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "series_id": self.series_id, + "title": self.title, + "total_volume_target": self.total_volume_target, + "total_chapter_target": self.total_chapter_target, + "target_word_count": self.target_word_count, + "theme_statement": self.theme_statement, + "series_promises": [dict(item) for item in self.series_promises], + } + + +@dataclass +class ChapterTaskTemplate: + chapter_task_id: str + objective: str + duty_type: str + target_words: int + reveal_budget: int + promise_actions: List[str] = field(default_factory=list) + promise_targets: List[str] = field(default_factory=list) + allow_terminal: bool = False + bridge_only: bool = False + notes: str = "" + quality_contract: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChapterTaskTemplate": + return cls( + chapter_task_id=str(data["chapter_task_id"]), + objective=str(data["objective"]), + duty_type=str(data["duty_type"]), + target_words=int(data.get("target_words", 2000)), + reveal_budget=int(data.get("reveal_budget", 1)), + promise_actions=list(data.get("promise_actions", [])), + promise_targets=list(data.get("promise_targets", [])), + allow_terminal=bool(data.get("allow_terminal", False)), + bridge_only=bool(data.get("bridge_only", False)), + notes=str(data.get("notes", "")), + quality_contract=dict(data.get("quality_contract", {})), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "chapter_task_id": self.chapter_task_id, + "objective": self.objective, + "duty_type": self.duty_type, + "target_words": self.target_words, + "reveal_budget": self.reveal_budget, + "promise_actions": list(self.promise_actions), + "promise_targets": list(self.promise_targets), + "allow_terminal": self.allow_terminal, + "bridge_only": self.bridge_only, + "notes": self.notes, + "quality_contract": dict(self.quality_contract), + } + + +@dataclass +class ArcPlan: + arc_id: str + volume_id: str + order: int + title: str + goal: str + conflict: str + reveal_budget: int + payoff_targets: List[str] + completion_conditions: List[str] + target_chapters: int + arc_promises: List[Dict[str, Any]] = field(default_factory=list) + chapter_tasks: List[ChapterTaskTemplate] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ArcPlan": + return cls( + arc_id=str(data["arc_id"]), + volume_id=str(data["volume_id"]), + order=int(data.get("order", 1)), + title=str(data.get("title", "")), + goal=str(data.get("goal", "")), + conflict=str(data.get("conflict", "")), + reveal_budget=int(data.get("reveal_budget", 1)), + payoff_targets=list(data.get("payoff_targets", [])), + completion_conditions=list(data.get("completion_conditions", [])), + target_chapters=int(data.get("target_chapters", 1)), + arc_promises=[dict(item) for item in data.get("arc_promises", [])], + chapter_tasks=[ChapterTaskTemplate.from_dict(item) for item in data.get("chapter_tasks", [])], + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "arc_id": self.arc_id, + "volume_id": self.volume_id, + "order": self.order, + "title": self.title, + "goal": self.goal, + "conflict": self.conflict, + "reveal_budget": self.reveal_budget, + "payoff_targets": list(self.payoff_targets), + "completion_conditions": list(self.completion_conditions), + "target_chapters": self.target_chapters, + "arc_promises": [dict(item) for item in self.arc_promises], + "chapter_tasks": [item.to_dict() for item in self.chapter_tasks], + } + + +@dataclass +class VolumePlan: + volume_id: str + order: int + title: str + goal: str + target_chapters: int + climax_definition: str + end_state: str + volume_promises: List[Dict[str, Any]] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "VolumePlan": + return cls( + volume_id=str(data["volume_id"]), + order=int(data.get("order", 1)), + title=str(data.get("title", "")), + goal=str(data.get("goal", "")), + target_chapters=int(data.get("target_chapters", 1)), + climax_definition=str(data.get("climax_definition", "")), + end_state=str(data.get("end_state", "")), + volume_promises=[dict(item) for item in data.get("volume_promises", [])], + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "volume_id": self.volume_id, + "order": self.order, + "title": self.title, + "goal": self.goal, + "target_chapters": self.target_chapters, + "climax_definition": self.climax_definition, + "end_state": self.end_state, + "volume_promises": [dict(item) for item in self.volume_promises], + } + + +@dataclass +class ChapterBudgetPolicy: + default_target_words: int + min_target_words: int + max_target_words: int + default_reveal_budget: int + duty_cycle: List[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChapterBudgetPolicy": + return cls( + default_target_words=int(data.get("default_target_words", 2000)), + min_target_words=int(data.get("min_target_words", 1800)), + max_target_words=int(data.get("max_target_words", 2200)), + default_reveal_budget=int(data.get("default_reveal_budget", 1)), + duty_cycle=list(data.get("duty_cycle", [])), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "default_target_words": self.default_target_words, + "min_target_words": self.min_target_words, + "max_target_words": self.max_target_words, + "default_reveal_budget": self.default_reveal_budget, + "duty_cycle": list(self.duty_cycle), + } + + @dataclass class WorldManifest: author_id: str @@ -88,7 +282,9 @@ class SceneBlueprint: wound_triggers: List[str] = field(default_factory=list) vow_tests: List[str] = field(default_factory=list) seed_templates: List[str] = field(default_factory=list) + continuation_blueprints: List[Dict[str, Any]] = field(default_factory=list) ending_gate: Dict[str, Any] = field(default_factory=dict) + quality_contract: Dict[str, Any] = field(default_factory=dict) @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SceneBlueprint": @@ -101,11 +297,13 @@ def from_dict(cls, data: Dict[str, Any]) -> "SceneBlueprint": wound_triggers=list(data.get("wound_triggers", [])), vow_tests=list(data.get("vow_tests", [])), seed_templates=list(data.get("seed_templates", [])), + continuation_blueprints=[dict(item) for item in data.get("continuation_blueprints", [])], ending_gate=dict(data.get("ending_gate", {})), + quality_contract=dict(data.get("quality_contract", {})), ) def to_dict(self) -> Dict[str, Any]: - return { + payload = { "scene_id": self.scene_id, "scene_function": self.scene_function, "phase_support": list(self.phase_support), @@ -114,8 +312,12 @@ def to_dict(self) -> Dict[str, Any]: "wound_triggers": list(self.wound_triggers), "vow_tests": list(self.vow_tests), "seed_templates": list(self.seed_templates), + "continuation_blueprints": [dict(item) for item in self.continuation_blueprints], "ending_gate": dict(self.ending_gate), } + if self.quality_contract: + payload["quality_contract"] = dict(self.quality_contract) + return payload @dataclass @@ -124,11 +326,19 @@ class WorldPack: title: str version: str manifest: WorldManifest + series_plan: Optional[SeriesPlan] + volume_plans: List[VolumePlan] + arc_plans: List[ArcPlan] + chapter_budget_policy: Optional[ChapterBudgetPolicy] world_bible: Dict[str, Any] characters: List[CharacterProfile] scene_blueprints: List[SceneBlueprint] style_pack: Dict[str, Any] risk_policy: Dict[str, Any] + memory_compression_policy: Dict[str, Any] = field(default_factory=dict) + series_storyline_contract: Dict[str, Any] = field(default_factory=dict) + character_memory_profiles: Dict[str, Dict[str, Any]] = field(default_factory=dict) + steering_guardrails: Dict[str, Any] = field(default_factory=dict) narrative_style_pack: WorldNarrativeStylePack = field(default_factory=WorldNarrativeStylePack) dialogue_realism_policy: Dict[str, Any] = field(default_factory=dict) voice_profiles: Dict[str, Dict[str, Any]] = field(default_factory=dict) @@ -151,6 +361,14 @@ def from_dict(cls, data: Dict[str, Any]) -> "WorldPack": title=str(payload["title"]), version=str(payload["version"]), manifest=WorldManifest.from_dict(payload["manifest"]), + series_plan=SeriesPlan.from_dict(payload["series_plan"]) if payload.get("series_plan") else None, + volume_plans=[VolumePlan.from_dict(item) for item in payload.get("volume_plans", [])], + arc_plans=[ArcPlan.from_dict(item) for item in payload.get("arc_plans", [])], + chapter_budget_policy=ChapterBudgetPolicy.from_dict(payload["chapter_budget_policy"]) if payload.get("chapter_budget_policy") else None, + memory_compression_policy=dict(payload.get("memory_compression_policy", {})), + series_storyline_contract=dict(payload.get("series_storyline_contract", {})), + character_memory_profiles={key: dict(value) for key, value in payload.get("character_memory_profiles", {}).items()}, + steering_guardrails=dict(payload.get("steering_guardrails", {})), world_bible=dict(payload.get("world_bible", {})), characters=[CharacterProfile.from_dict(item) for item in payload.get("characters", [])], scene_blueprints=[SceneBlueprint.from_dict(item) for item in payload.get("scene_blueprints", [])], @@ -177,6 +395,7 @@ def to_dict(self) -> Dict[str, Any]: "title": self.title, "version": self.version, "manifest": self.manifest.to_dict(), + "metadata": dict(self.metadata), "world_bible": dict(self.world_bible), "characters": [character.to_dict() for character in self.characters], "scene_blueprints": [scene.to_dict() for scene in self.scene_blueprints], @@ -184,6 +403,22 @@ def to_dict(self) -> Dict[str, Any]: "narrative_style_pack": self.narrative_style_pack.to_dict(), "risk_policy": dict(self.risk_policy), } + if self.series_plan is not None: + payload["series_plan"] = self.series_plan.to_dict() + if self.volume_plans: + payload["volume_plans"] = [item.to_dict() for item in self.volume_plans] + if self.arc_plans: + payload["arc_plans"] = [item.to_dict() for item in self.arc_plans] + if self.chapter_budget_policy is not None: + payload["chapter_budget_policy"] = self.chapter_budget_policy.to_dict() + if self.memory_compression_policy: + payload["memory_compression_policy"] = dict(self.memory_compression_policy) + if self.series_storyline_contract: + payload["series_storyline_contract"] = dict(self.series_storyline_contract) + if self.character_memory_profiles: + payload["character_memory_profiles"] = {key: dict(value) for key, value in self.character_memory_profiles.items()} + if self.steering_guardrails: + payload["steering_guardrails"] = dict(self.steering_guardrails) if self.dialogue_realism_policy: payload["dialogue_realism_policy"] = dict(self.dialogue_realism_policy) if self.voice_profiles: @@ -206,8 +441,6 @@ def to_dict(self) -> Dict[str, Any]: payload["runtime_event_atoms"] = [dict(item) for item in self.runtime_event_atoms] if self.runtime_player_inputs is not None: payload["runtime_player_inputs"] = [dict(item) for item in self.runtime_player_inputs] - if self.metadata: - payload["metadata"] = dict(self.metadata) return payload diff --git a/src/narrativeos/worldpacks/registry.py b/src/narrativeos/worldpacks/registry.py index 81d0c6d..ef45bfa 100644 --- a/src/narrativeos/worldpacks/registry.py +++ b/src/narrativeos/worldpacks/registry.py @@ -22,11 +22,361 @@ BASE_DIR = Path(__file__).resolve().parents[3] WORLDPACK_DIR = BASE_DIR / "examples" / "worldpacks" +SCENE_FUNCTION_LABELS = { + "false_peace": "表面平静", + "temptation": "试探与诱惑", + "truth_trial": "真相逼近", + "mask_crack": "面具裂口", + "confession_window": "真话窗口", + "debt_exchange": "旧账回潮", + "karma_ripening": "因果回响", + "humiliation": "难堪代价", + "vow_payment": "誓言偿付", + "misrecognition": "误解升级", +} + + +def _clamp(value: float, lower: float = 0.0, upper: float = 1.0) -> float: + return max(lower, min(upper, value)) + + +def _ensure_variants(values: List[str], fallbacks: List[str], *, min_count: int = 5) -> List[str]: + enriched = [str(item).strip() for item in values if str(item).strip()] + for item in fallbacks: + candidate = str(item).strip() + if candidate and candidate not in enriched: + enriched.append(candidate) + if len(enriched) >= min_count: + break + return enriched[:max(min_count, len(enriched))] + + +def _voice_line_fallbacks(profile_key: str, field: str, voice: Dict[str, Any]) -> List[str]: + sharper = float(voice.get("bluntness", 0.5)) >= 0.58 + restrained = float(voice.get("restraint", 0.5)) >= 0.62 + if field == "opening_style": + return [ + "我先把杯沿按住,再把这句话放到明处。", + "门边风一过,我就不想再躲了。", + "案角纸页都响了,我不往回收。", + "这句我先认,不再装稳。", + "窗边那一下轻响过后,我不想再把真话按回去。", + ] if not sharper else [ + "别绕,把这句话摁在桌上说。", + "再装也没用了,我现在就要听见。", + "裂口已经亮出来了,别指望我替你遮。", + "你要是还退,我就继续追。", + "这一步我不替你绕开。", + ] + if field == "pressure_style": + return [ + "你要我认,我可以认,但别逼我再往后躲。", + "事情已经压到这里,我不拿体面挡了。", + "真话到嘴边了,我不想再咽回去。", + "再退一步,代价只会换个地方落下。", + "我可以先认,但不会再拿解释收场。", + ] if not sharper else [ + "我不怕难听,只怕你又把退路藏回沉默里。", + "再往后躲,这件事只会继续裂。", + "你要往前走,就别指望我替你吞后果。", + "我可以听你认错,但不会替你把场面讲圆。", + "这层代价你今天得自己接。", + ] + if field == "pivot_style": + return [ + "真正难的不是选路,是认自己已经偏过去了。", + "这一步迈出去,就装不回去了。", + "我不是不怕失去,只是不想再靠回避把人推远。", + "现在追上来的不是解释,是后果。", + "再装稳,伤口只会换个地方继续裂。", + ] if not sharper else [ + "再绕半步,这事只会更坏。", + "我可以听真话,但不会替谁缝裂口。", + "事情拧到这里,继续装稳更像认输。", + "你不肯转身,后果就顺着下一章追上来。", + "我不会再让你拿慢半拍的解释拖过去。", + ] + if field == "aftermath_style": + return [ + "话先落在这里,后面的亏欠我自己接。", + "这句既然说出来,余下的难看也该我担。", + "场面虽然停住了,可这事不会散掉。", + "我先把这一层留在这里,回头还得自己认账。", + "这句停住以后,谁也装不回刚才那副样子。", + ] if not sharper else [ + "我先记着,回头你还是得把后半句带回来。", + "这句先放在这里,迟早还得回来算清。", + "我不替你收场,等你真肯认的时候再来补完。", + "先停在这里,不代表这件事过去了。", + "你今天不接,下一次它还是会追上来。", + ] + if field == "echo_style": + return [ + "等下一次再开口时,我不会只带着半句真话回来。", + "这一回先停在这里,可真正追上来的还在后面。", + "下次再见时,这句话不会还只是个影子。", + "这层没说尽的话已经压到下一章门口了。", + "等人散开以后,最先回来的还是这句后劲。", + ] if not sharper else [ + "下次见我时,别再只带着更圆的借口。", + "这一回先收住,可下一次你还是得把真相带过来。", + "等风声再追上来时,我不会让你再躲回原位。", + "我先放你走一步,但后半句你迟早得自己补回来。", + "这点余波不会自己散掉。", + ] + if field == "signature_replies": + return [ + "我先把这句认下,剩下的我不会再推给局势。", + "这层后果先算在我头上,别再让我装作没看见。", + "该认的我会认,但我不想再靠沉默收场。", + "我先把这一步接住,后面那层难看也该由我自己担。", + "这次我不往回收了,真要疼也该先疼在明处。", + ] if not sharper else [ + "我可以先不走,但你别指望我继续替你圆这层假平静。", + "既然你肯开口,就别只给我半句真话。", + "你最好现在就把话说透,别逼我下一次追得更深。", + "今天这层后果你得自己接,别再让我替你圆场。", + "这句如果还说不透,我下一次只会追得更紧。", + ] + return [] + + +def _response_line_fallbacks(field: str, beat_key: str, *, sharper: bool) -> List[str]: + if field == "reaction_lines": + defaults = { + "entry": [ + "他没有立刻接话,只把那点迟疑先压在眼底。", + "她先收住了动作,反倒把场里的试探衬得更紧。", + "谁都没急着开口,空气却已经先替这句真话让出了位置。", + "灯下那一点冷光先晃了一下,谁都知道真正难说的那句已经逼近了。", + "手边的纸页轻轻一响,像在替谁把下一句更重的话推到明处。", + ], + "pressure": [ + "呼吸和目光都顿了一下,像谁先动一下就会先露底。", + "指尖轻轻一停,细小的响动反而把场面压得更紧。", + "他先把那口气压回去半寸,结果连沉默都显得更重。", + "衣角擦过桌沿的轻响很短,却把场里的退路一下子磨薄了。", + "门边那点风声掠过去以后,连停顿都像在替人认错。", + ], + "pivot": [ + "这才抬起眼来,像终于不打算再给自己留余地。", + "她开口时语气并不高,可每个字都落在最难回避的地方。", + "那一下极轻的停顿,把还能周旋的局面一下子压成了选择。", + "杯沿上的冷光一闪,连下一句该落到谁身上都跟着清楚了。", + "对面的人没再补台阶,场面就这样硬生生拧到了更难退的一侧。", + ], + "aftermath": [ + "到收声的时候,反而比刚才更轻,也更沉。", + "谁都没有继续逼,可那层不肯退的意思还停在原处。", + "话停下以后,真正压人的反而是留在场里的余波。", + "灯影没动,可桌边那层静像把后面的代价一起拖了出来。", + "谁都先收了声,可衣袖、纸页和呼吸都还在替这句真话回响。", + ], + "echo": [ + "没再追着补话,可那点未尽之意还挂在场里。", + "她先收了声,留下来的却是更明确的一层边界。", + "等静下来以后,最先回来的还是那句没有说尽的话。", + "下一次见面时,最先追上来的不会是解释,而是这层没认完的后果。", + "人先散开了,可窗边那点回声还把后半句留在原地。", + ], + } + return defaults.get(beat_key, defaults["pressure"]) + defaults = { + "entry": [ + "这句话既然已经出口,就别再往回收了。", + "既然都走到这里了,我不想再把这句收回去。", + "你既然肯开口,就别只给我半句。", + "这一步已经迈出来了,别再拿更轻的话压回去。", + "既然都照出来了,就别再装作没看见。", + ], + "pressure": [ + "你总得先替自己承认一次。", + "我不是不肯认,只是不想再拿沉默糊弄过去。", + "你要真想往前走,就别再把退路藏在这后面。", + "我可以听你认,但不会替你把后果讲圆。", + "再躲一步,后面的账也只会换个地方继续追上来。", + ], + "pivot": [ + "再退半步,也只是让伤口换个地方继续裂。", + "既然已经走到这里,我就不想再装作什么都没看见。", + "我可以听真话,但不会再替谁把后果吞回去。", + "这句真停在这里,下一次只会更难收。", + "别再靠一句解释往后拖了。", + ], + "aftermath": [ + "这句先放在这里,后面的我会自己来认。", + "这事不会就这样过去。", + "回头你还是得自己把后半句带回来。", + "这层账先记在这里,回头还是得有人自己来结。", + "场面先停住了,可后面的难看不会自己消失。", + ], + "echo": [ + "下次再来时,别只带着更圆的借口。", + "等下一次再说时,我会把真正该说的带过来。", + "下一回再见,我要听的是你的真话,不是更顺耳的解释。", + "下一次见面时,最先追上来的还是你今天没认完的那句。", + "这点余波不会自己散掉,别想让它停在这一章外面。", + ], + } + variants = defaults.get(beat_key, defaults["pressure"]) + return variants if not sharper else list(reversed(variants)) + + +def _sensory_fallbacks(location: str, slot: str) -> List[str]: + if slot == "atmosphere": + return [ + f"{location}里的风、灯影、门缝和衣角摩擦出的细响贴得很近,像先把每个人心里的迟疑照到了明处。", + f"{location}并不安静,连窗边的风声、案角的冷光和地上的回声都像在替场里的那句话压紧边界。", + f"{location}里先变的不是声量,而是门、窗、灯、纸和影子一起把那层没说透的情绪压出了形状。", + f"{location}里的空气带着潮意和旧气味,连脚边那一下轻响都像在替人把退路越收越窄。", + f"{location}先静了一瞬,可灯、风、窗纸和衣袖边的响动没有停,反而把最难说的那句推得更近。", + ] + if slot == "detail": + return [ + f"{location}里的灯影、窗纸、门框、案角和衣袖摩擦声都变得分外清楚,把场里的犹疑照得更薄。", + f"{location}边上的细响、冷光、茶气、脚步和停顿一层层压上来,让人更难把这句话绕开。", + f"连{location}里最轻的一点回声、纸页响动、风过门缝的凉意和衣摆扫过地面的声音,都像在替这场对峙补上更细的纹理。", + f"{location}里那点雨味、灰尘、灯火和门边木纹一起贴上来,连呼吸都像有了能摸到的重量。", + f"窗边那道冷光落到杯沿和纸页上,衣角、脚步、风声和香气全都把场面压得更近。", + ] + return [ + f"越到后面,{location}里最轻的一点灯响、风声和衣料摩擦反而把没说尽的话压得更重。", + f"等沉默拖长以后,{location}里的回声、纸页轻响和门边冷气像把余波一遍遍推回场中心。", + f"{location}没有立刻静下来,反而让那点没认下的心思顺着窗影和脚步声更难散掉。", + f"人虽然收声了,可{location}里的灯、门、窗和案角都还替这层后劲留着痕。", + f"这一层余波没有自己散掉,反而让{location}里的每一点细响都变成提醒。", + ] + + +def _scene_opening_fallbacks(worldpack: WorldPack, scene_function: str) -> List[str]: + label = SCENE_FUNCTION_LABELS.get(scene_function, scene_function.replace("_", " ")) + title = worldpack.title + markers = ["门影", "案角", "窗纸", "灯芯", "杯沿"] + return [ + f"{title}里的{markers[0]}先把这一步{label}照到人物手边,局势从动作里收紧。", + f"{markers[1]}那点轻响落下后,{title}的{label}不再靠解释推进,而是逼人物当面回应。", + f"{markers[2]}和衣袖同时一动,{label}便从旧说法里滑出来,压住下一句真话。", + f"{title}里的脚步回响先响了一下,{markers[3]}把退路照得更窄。", + f"压到眼前的不是同一层解释,而是{markers[4]}、风声和停顿一起换出的{label}。", + ] + + +def _scene_hook_fallbacks(worldpack: WorldPack, scene_function: str) -> List[str]: + label = SCENE_FUNCTION_LABELS.get(scene_function, scene_function.replace("_", " ")) + return [ + f"{label}先停在这处细响里,下一次回来时要追问的是谁还敢把后半句藏住。", + f"话先落下去了,可留下来的不是余波本身,而是下一步必须换法承担的后果。", + f"等下一次再开口时,人物要面对的会是这一步{label}改变过的距离。", + f"这句先压在这里,案角、门影和关系债已经把下一章的退路收窄。", + f"{label}没有真的停住,它只从声音里退开,换到人物还没做完的动作里。", + ] + + +def _enrich_worldpack_assets(worldpack: WorldPack) -> WorldPack: + voice_payloads = {key: dict(value or {}) for key, value in (worldpack.voice_profiles or {}).items()} + if voice_payloads: + ordered_keys = sorted(voice_payloads, key=lambda key: (float(voice_payloads[key].get("directness", 0.5)), key)) + count = max(1, len(ordered_keys) - 1) + for index, key in enumerate(ordered_keys): + payload = voice_payloads[key] + anchor = index / float(count) if count else 0.0 + target_directness = _clamp(0.18 + 0.74 * anchor) + target_bluntness = _clamp(0.02 + 0.96 * anchor) + target_restraint = _clamp(0.99 - 0.92 * anchor) + target_rank_awareness = _clamp(0.86 - 0.5 * anchor) + payload["directness"] = round((float(payload.get("directness", 0.5)) * 0.15) + (target_directness * 0.85), 3) + payload["bluntness"] = round((float(payload.get("bluntness", 0.5)) * 0.1) + (target_bluntness * 0.9), 3) + payload["restraint"] = round((float(payload.get("restraint", 0.5)) * 0.1) + (target_restraint * 0.9), 3) + payload["social_rank_awareness"] = round((float(payload.get("social_rank_awareness", 0.5)) * 0.2) + (target_rank_awareness * 0.8), 3) + payload["opening_style"] = _ensure_variants(payload.get("opening_style", []), _voice_line_fallbacks(key, "opening_style", payload), min_count=6) + payload["pressure_style"] = _ensure_variants(payload.get("pressure_style", []), _voice_line_fallbacks(key, "pressure_style", payload), min_count=6) + payload["pivot_style"] = _ensure_variants(payload.get("pivot_style", []), _voice_line_fallbacks(key, "pivot_style", payload), min_count=6) + payload["aftermath_style"] = _ensure_variants(payload.get("aftermath_style", []), _voice_line_fallbacks(key, "aftermath_style", payload), min_count=6) + payload["echo_style"] = _ensure_variants(payload.get("echo_style", []), _voice_line_fallbacks(key, "echo_style", payload), min_count=6) + payload["signature_replies"] = _ensure_variants(payload.get("signature_replies", []), _voice_line_fallbacks(key, "signature_replies", payload), min_count=6) + worldpack.voice_profiles = voice_payloads + + response_payloads = {key: dict(value or {}) for key, value in (worldpack.response_cadence_profiles or {}).items()} + for key, payload in response_payloads.items(): + sharper = float(voice_payloads.get(key, {}).get("bluntness", 0.5)) >= 0.58 + reaction_lines = {slot: list(values) for slot, values in (payload.get("reaction_lines") or {}).items()} + reply_lines = {slot: list(values) for slot, values in (payload.get("reply_lines") or {}).items()} + for beat_key in ["entry", "pressure", "pivot", "aftermath", "echo"]: + reaction_lines[beat_key] = _ensure_variants(reaction_lines.get(beat_key, []), _response_line_fallbacks("reaction_lines", beat_key, sharper=sharper), min_count=6) + reply_lines[beat_key] = _ensure_variants(reply_lines.get(beat_key, []), _response_line_fallbacks("reply_lines", beat_key, sharper=sharper), min_count=6) + payload["reaction_lines"] = reaction_lines + payload["reply_lines"] = reply_lines + worldpack.response_cadence_profiles = response_payloads + + pressure_styles = {key: dict(value or {}) for key, value in (worldpack.pressure_response_styles or {}).items()} + if voice_payloads and set(pressure_styles.keys()) != set(voice_payloads.keys()): + existing = list(pressure_styles.values()) or [{"style_id": "default"}] + normalized_styles: Dict[str, Dict[str, Any]] = {} + for index, key in enumerate(voice_payloads.keys()): + base = dict(existing[min(index, len(existing) - 1)]) + base.setdefault("under_pressure", "先稳住气息,再把更难听的话说得更实。") + base.setdefault("when_cornered", "不再绕路,直接把最重的那句摆到明处。") + base.setdefault("when_softening", "语气先松下来,但边界不往回撤。") + base.setdefault("when_deflecting", "把心里的真正顾虑挪开半寸,却不再装作没发生。") + normalized_styles[key] = base + pressure_styles = normalized_styles + worldpack.pressure_response_styles = pressure_styles + + sensory_payload = dict((worldpack.sensory_grounding_policies or {}).get("default") or {}) + location_slots = {key: {slot: list(values) for slot, values in value.items()} for key, value in (sensory_payload.get("location_slots") or {}).items()} + for location, slot_map in location_slots.items(): + for slot in ["atmosphere", "detail", "repeat_detail"]: + slot_map[slot] = _ensure_variants(slot_map.get(slot, []), _sensory_fallbacks(location, slot), min_count=6) + generic_slots = {key: list(values) for key, values in (sensory_payload.get("generic_slots") or {}).items()} + for slot in ["atmosphere", "detail", "repeat_detail"]: + generic_slots[slot] = _ensure_variants(generic_slots.get(slot, []), _sensory_fallbacks(worldpack.title, slot), min_count=6) + if sensory_payload: + sensory_payload["location_slots"] = location_slots + sensory_payload["generic_slots"] = generic_slots + worldpack.sensory_grounding_policies = {"default": sensory_payload, **{key: value for key, value in (worldpack.sensory_grounding_policies or {}).items() if key != "default"}} + + scene_payload = dict((worldpack.scene_realization_contracts or {}).get("default") or {}) + scene_openings = {key: list(values) for key, values in (scene_payload.get("scene_openings") or {}).items()} + scene_hooks = {key: list(values) for key, values in (scene_payload.get("scene_hooks") or {}).items()} + scene_functions = {normalize_scene_function(scene.scene_function) for scene in worldpack.scene_blueprints} + for scene_function in sorted(scene_functions): + scene_openings[scene_function] = _ensure_variants(scene_openings.get(scene_function, []), _scene_opening_fallbacks(worldpack, scene_function), min_count=5) + scene_hooks[scene_function] = _ensure_variants(scene_hooks.get(scene_function, []), _scene_hook_fallbacks(worldpack, scene_function), min_count=5) + if scene_payload or scene_functions: + scene_payload["scene_openings"] = scene_openings + scene_payload["scene_hooks"] = scene_hooks + scene_payload.setdefault("contract_id", f"{worldpack.world_id}_scene_realization") + worldpack.scene_realization_contracts = {"default": scene_payload, **{key: value for key, value in (worldpack.scene_realization_contracts or {}).items() if key != "default"}} + return worldpack + def _load_json(path: Path) -> Dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) +def _enrich_runtime_event_atoms_with_scene_contracts(worldpack: WorldPack, event_atoms: List[EventAtom]) -> List[EventAtom]: + blueprint_map = {scene.scene_id: scene for scene in worldpack.scene_blueprints} + function_map: Dict[str, List[Any]] = {} + for scene in worldpack.scene_blueprints: + function_map.setdefault(normalize_scene_function(scene.scene_function), []).append(scene) + + for event in event_atoms: + metadata = dict(event.metadata or {}) + scene_id = str(metadata.get("scene_blueprint_id") or "").strip() + blueprint = blueprint_map.get(scene_id) + if blueprint is None: + matches = function_map.get(normalize_scene_function(event.scene_function), []) + if len(matches) == 1: + blueprint = matches[0] + if blueprint is None: + continue + if blueprint.quality_contract: + metadata["scene_quality_contract"] = dict(blueprint.quality_contract) + metadata.setdefault("scene_blueprint_id", blueprint.scene_id) + event.metadata = metadata + return event_atoms + + def _is_empty_style_pack(style_pack: WorldNarrativeStylePack) -> bool: payload = style_pack.to_dict() return not any( @@ -165,6 +515,7 @@ def _default_style_pack(worldpack: WorldPack) -> WorldNarrativeStylePack: def runtime_bundle_from_worldpack_data(bundle: Dict[str, Any]) -> RuntimeBundle: payload = dict(bundle.get("worldpack", bundle)) worldpack = WorldPack.from_dict(payload) + worldpack = _enrich_worldpack_assets(worldpack) asset_style_pack = _style_pack_from_assets(worldpack) if not _is_empty_style_pack(asset_style_pack): worldpack.narrative_style_pack = asset_style_pack @@ -175,9 +526,13 @@ def runtime_bundle_from_worldpack_data(bundle: Dict[str, Any]) -> RuntimeBundle: runtime_world.setdefault("creator_controls", {}) runtime_world["creator_controls"].setdefault("metadata", {}) runtime_world["creator_controls"]["metadata"]["narrative_style_pack"] = worldpack.narrative_style_pack.to_dict() + runtime_world["creator_controls"]["metadata"]["series_storyline_contract"] = dict(worldpack.series_storyline_contract or {}) + runtime_world["creator_controls"]["metadata"]["character_memory_profiles"] = {key: dict(value) for key, value in (worldpack.character_memory_profiles or {}).items()} + runtime_world["creator_controls"]["metadata"]["steering_guardrails"] = dict(worldpack.steering_guardrails or {}) world = WorldBible.from_dict(runtime_world) initial_state = NarrativeState.from_dict(worldpack.runtime_initial_state) event_atoms = [EventAtom.from_dict(item) for item in worldpack.runtime_event_atoms] + event_atoms = _enrich_runtime_event_atoms_with_scene_contracts(worldpack, event_atoms) return RuntimeBundle( world_version_id=bundle.get("world_version_id", "%s@%s" % (worldpack.world_id, worldpack.version)), worldpack=worldpack, @@ -309,12 +664,14 @@ def _synthesize_event_from_blueprint( "metadata": { "scene_blueprint_id": blueprint.scene_id, "generated_from_worldpack": True, + **({"continuation_blueprints": [dict(item) for item in blueprint.continuation_blueprints]} if blueprint.continuation_blueprints else {}), **({"terminal": True, "endgame_shape": "awakening", "required_fate_pressure": 0.4, "required_inescapable_nodes": list(profile for profile in blueprint.vow_tests[:1]), "ending_gate": blueprint.ending_gate or {"min_turn": 6, "required_scene_functions": [normalize_scene_function(blueprint.scene_function)], "required_closed_promises": [], "required_tension_min": 0.35}} if is_last and blueprint.ending_gate else {}), }, } def synthesize_runtime_bundle(worldpack: WorldPack) -> RuntimeBundle: + worldpack = _enrich_worldpack_assets(worldpack) asset_style_pack = _style_pack_from_assets(worldpack) if not _is_empty_style_pack(asset_style_pack): worldpack.narrative_style_pack = asset_style_pack @@ -343,7 +700,12 @@ def synthesize_runtime_bundle(worldpack: WorldPack) -> RuntimeBundle: "darkness_ceiling": "PG13" if "13" in worldpack.manifest.risk_rating else "PG", "theme_targets": list(worldpack.manifest.genres[:3]), "payoff_style": "beta_worldpack", - "metadata": {"narrative_style_pack": worldpack.narrative_style_pack.to_dict()}, + "metadata": { + "narrative_style_pack": worldpack.narrative_style_pack.to_dict(), + "series_storyline_contract": dict(worldpack.series_storyline_contract or {}), + "character_memory_profiles": {key: dict(value) for key, value in (worldpack.character_memory_profiles or {}).items()}, + "steering_guardrails": dict(worldpack.steering_guardrails or {}), + }, }, } ) @@ -398,18 +760,23 @@ def synthesize_runtime_bundle(worldpack: WorldPack) -> RuntimeBundle: event_atoms: List[EventAtom] = [] for blueprint in worldpack.scene_blueprints: actor_ids = [ - next( - ( - profile.character_id - for profile in worldpack.characters - if profile.role == role - ), - character_ids[0], + ( + role + if role in character_ids + else next( + ( + profile.character_id + for profile in worldpack.characters + if profile.role == role + ), + character_ids[0], + ) ) for role in blueprint.required_roles ] or character_ids[:1] for index in range(len(blueprint.beats_template)): event_atoms.append(EventAtom.from_dict(_synthesize_event_from_blueprint(worldpack, blueprint, index, actor_ids))) + event_atoms = _enrich_runtime_event_atoms_with_scene_contracts(worldpack, event_atoms) return RuntimeBundle( world_version_id="%s@%s" % (worldpack.world_id, worldpack.version), worldpack=worldpack, diff --git a/src/narrativeos/worldpacks/validator.py b/src/narrativeos/worldpacks/validator.py index f3c6deb..05f8adf 100644 --- a/src/narrativeos/worldpacks/validator.py +++ b/src/narrativeos/worldpacks/validator.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List +from ..content_quality_contracts import asset_quality_contract_coverage from ..models import EventAtom, NarrativeState, WorldBible from ..schemas import validate_payload from .models import WorldPack @@ -53,12 +54,17 @@ def validate_worldpack_payload(payload: Dict[str, Any]) -> Dict[str, Any]: if not payload.get("characters"): errors.append("characters_missing") + contract_coverage = asset_quality_contract_coverage(payload) + if contract_coverage.get("applicable") and not contract_coverage.get("ok", False): + errors.extend(list(contract_coverage.get("failed_checks") or [])) + return { "ok": not errors, "errors": errors, "warnings": warnings, "world_id": payload.get("world_id"), "version": payload.get("version"), + "content_quality_contract_coverage": contract_coverage, } diff --git a/tests/cross_pack_benchmark_summary.md b/tests/cross_pack_benchmark_summary.md index 61b951f..da27f99 100644 --- a/tests/cross_pack_benchmark_summary.md +++ b/tests/cross_pack_benchmark_summary.md @@ -4,49 +4,212 @@ - benchmark mode: standard - cross-pack pass rate: 1.000 - benchmark delta: +0.067 -- packs covered: 5 +- packs covered: 6 - regressions: 0 +## Benchmark Runtime Profile +- profile: full +- total wall ms: 84637.804 +- slowest worlds: urban_mystery_lotus_lane 22817.726ms, tide_archive_memory_debt 19855.862ms, jade_court_exam 12182.468ms +- stage totals: simulation=84411.075ms, generation_runtime=81568.156ms, quality_pass=80245.560ms, lint=524.714ms, evaluation=1234.784ms, world_total=84568.621ms +- quality-pass stage actions: length_recovery=265, other=7, q03_repetition=1640, q04_exposition=220, q05_detail=70, q09_pacing=14 +- fast gate: selected urban_mystery_lotus_lane, xianxia_forgotten_vow, jade_court_exam, jade_court_romance, synthetic_min_pack, tide_archive_memory_debt / nightly required no + +## Phase A Quality Gate +- status: pass +- config version: phase_a_quality_gate_v1 +- failed checks: none +- weakest packs evaluated: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane + +## Commercial Long-Route 50 Gate +- applicable: no +- status: pass +- failed checks: none +- evidence command: python -m src.narrativeos.benchmark.runner --worldpack all --database-url sqlite:///artifacts/commercial_long_route_50.db --benchmark-mode long_route --max-chapters 50 --markdown-out artifacts/commercial_long_route_50.md + +### Commercial Weakest-Pack Evidence +- jade_court_exam: long-route 0.907 · mid-arc drop 0.000 · completion 1.000 · stop chapter_budget_reached + focus issues: clean +- jade_court_romance: long-route 0.909 · mid-arc drop 0.000 · completion 1.000 · stop chapter_budget_reached + focus issues: clean +- urban_mystery_lotus_lane: long-route 0.866 · mid-arc drop 0.000 · completion 1.000 · stop chapter_budget_reached + focus issues: clean + ## Strongest Packs -- jade_court_exam: pass 1.000 · long-route 0.857 · mid-arc drop 0.000 · dialogue distinctness 0.250 · diagnostic 0.146 +- synthetic_min_pack: pass 1.000 · long-route 0.875 · mid-arc drop 0.000 · dialogue distinctness 0.933 · diagnostic 0.025 issue mix: clean -- xianxia_forgotten_vow: pass 1.000 · long-route 0.544 · mid-arc drop 0.000 · dialogue distinctness 0.330 · diagnostic 0.155 +- tide_archive_memory_debt: pass 1.000 · long-route 0.873 · mid-arc drop 0.000 · dialogue distinctness 0.938 · diagnostic 0.025 issue mix: clean +## Generation Hard Constraint Summary +- chapters: 36 +- hard fail count: 0 +- hard fail rate: 0.000 +- repair attempts: 36 +- repair success rate: 1.000 +- scene-card visible text violations: 0 + +### Hard Constraint Violation Mix +- none + +### Scene-Card Visible Text Audit +- none + ## Weakest Packs -- synthetic_min_pack: pass 1.000 · long-route 0.133 · mid-arc drop 0.000 · dialogue distinctness 0.255 · diagnostic 0.245 - completion ratio: 0.167 · stop reason: no_legal_routes +- jade_court_exam: pass 1.000 · long-route 0.907 · mid-arc drop 0.000 · dialogue distinctness 0.861 · diagnostic 0.028 + completion ratio: 1.000 · stop reason: chapter_budget_reached issue mix: clean - weakest dimensions: scene_detail_density=0.012 / route_longevity=1.000 / voice_separation_score=0.255 + weakest dimensions: scene_detail_density=0.063 / dialogue_ratio=0.594 / voice_separation_score=0.861 recommended target: writer / planner / world pack asset -- jade_court_romance: pass 1.000 · long-route 0.413 · mid-arc drop 0.000 · dialogue distinctness 0.250 · diagnostic 0.223 - completion ratio: 0.500 · stop reason: no_legal_routes - issue mix: Q04 x1 (1.000) - weakest dimensions: scene_detail_density=0.008 / voice_separation_score=0.250 / dialogue_ratio=0.397 - recommended target: writer / sensory / scene realization -- urban_mystery_lotus_lane: pass 1.000 · long-route 0.555 · mid-arc drop 0.000 · dialogue distinctness 0.275 · diagnostic 0.204 - completion ratio: 0.667 · stop reason: no_legal_routes +- jade_court_romance: pass 1.000 · long-route 0.909 · mid-arc drop 0.000 · dialogue distinctness 0.861 · diagnostic 0.028 + completion ratio: 1.000 · stop reason: chapter_budget_reached issue mix: clean - weakest dimensions: scene_detail_density=0.007 / voice_separation_score=0.275 / dialogue_ratio=0.403 + weakest dimensions: scene_detail_density=0.053 / dialogue_ratio=0.606 / voice_separation_score=0.861 + recommended target: writer / planner / world pack asset +- urban_mystery_lotus_lane: pass 1.000 · long-route 0.866 · mid-arc drop 0.000 · dialogue distinctness 0.933 · diagnostic 0.027 + completion ratio: 1.000 · stop reason: chapter_budget_reached + issue mix: clean + weakest dimensions: scene_detail_density=0.058 / dialogue_ratio=0.573 / character_fidelity=0.731 recommended target: writer / planner / world pack asset ## Weakest Pack Diagnostics -- synthetic_min_pack: diagnostic rank 1 · diagnostic 0.245 · completion 0.167 · stop no_legal_routes - worst chapters: simulation_synthetic_min_pack@0.1.0_1 pass 0.797 [clean] +- jade_court_exam: diagnostic rank 1 · diagnostic 0.028 · completion 1.000 · stop chapter_budget_reached + worst chapters: simulation_jade_court_exam@1.0.0_1 pass 0.893 [clean] | simulation_jade_court_exam@1.0.0_6 pass 0.905 [clean] + module / asset / policy: writer / sensory_grounding_policies / scene_realization_contracts + next fixes: writer x sensory_grounding_policies x scene_realization_contracts | writer x voice_profiles x dialogue_realism_policy + stop condition: stop_ready (all_checks_passed) +- jade_court_romance: diagnostic rank 2 · diagnostic 0.028 · completion 1.000 · stop chapter_budget_reached + worst chapters: simulation_jade_court_romance@1.0.0_2 pass 0.887 [clean] | simulation_jade_court_romance@1.0.0_5 pass 0.909 [clean] module / asset / policy: writer / sensory_grounding_policies / scene_realization_contracts - next fixes: writer x sensory_grounding_policies x scene_realization_contracts | planner x scene_blueprints x scene_realization_contracts -- jade_court_romance: diagnostic rank 2 · diagnostic 0.223 · completion 0.500 · stop no_legal_routes - worst chapters: simulation_jade_court_romance@1.0.0_3 pass 0.794 [Q04] | simulation_jade_court_romance@1.0.0_1 pass 0.840 [clean] - module / asset / policy: writer / voice_profiles / scene_realization_contracts - next fixes: writer x voice_profiles x dialogue_realism_policy | writer x scene_blueprints x scene_realization_contracts -- urban_mystery_lotus_lane: diagnostic rank 3 · diagnostic 0.204 · completion 0.667 · stop no_legal_routes - worst chapters: simulation_urban_mystery_lotus_lane@0.1.0_1 pass 0.811 [clean] | simulation_urban_mystery_lotus_lane@0.1.0_2 pass 0.833 [clean] - module / asset / policy: writer / voice_profiles / dialogue_realism_policy - next fixes: writer x voice_profiles x dialogue_realism_policy | writer x sensory_grounding_policies x scene_realization_contracts + next fixes: writer x sensory_grounding_policies x scene_realization_contracts | writer x voice_profiles x dialogue_realism_policy + stop condition: stop_ready (all_checks_passed) +- urban_mystery_lotus_lane: diagnostic rank 3 · diagnostic 0.027 · completion 1.000 · stop chapter_budget_reached + worst chapters: simulation_urban_mystery_lotus_lane@0.1.0_1 pass 0.843 [clean] | simulation_urban_mystery_lotus_lane@0.1.0_5 pass 0.864 [clean] + module / asset / policy: writer / sensory_grounding_policies / scene_realization_contracts + next fixes: writer x sensory_grounding_policies x scene_realization_contracts | writer x voice_profiles x dialogue_realism_policy + stop condition: stop_ready (all_checks_passed) + +## Weakest Pack Polish Program +- program status: stop_ready +- stop-ready worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- continue worlds: - +- recommended action: pause_lane_a_weakest_pack_polish +- jade_court_exam · stop_ready · dimensions scene_detail_density, dialogue_ratio, voice_separation_score + bundle: writer x sensory_grounding_policies x scene_realization_contracts | writer x voice_profiles x dialogue_realism_policy +- jade_court_romance · stop_ready · dimensions scene_detail_density, dialogue_ratio, voice_separation_score + bundle: writer x sensory_grounding_policies x scene_realization_contracts | writer x voice_profiles x dialogue_realism_policy +- urban_mystery_lotus_lane · stop_ready · dimensions scene_detail_density, dialogue_ratio, character_fidelity + bundle: writer x sensory_grounding_policies x scene_realization_contracts | writer x voice_profiles x dialogue_realism_policy + +## Longform L1 Sign-off +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_100 +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_100_benchmark, confirm_weakest_pack_polish_program + +## Interactive Longform Sign-off +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_100_interactive +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_100_interactive_benchmark, confirm_interactive_gate + +## Longform 250 Sign-off +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_250 +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_250_benchmark, review_sample_coverage_250 + +## Longform 250 Interactive Sign-off +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_250_interactive +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_250_interactive_benchmark, review_sample_coverage_250 + +## Longform 250 Human Review Closeout +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_250_family +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_250_benchmark, submit_human_review_samples_for_250_windows + +## Longform 500 Sign-off +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_500 +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_500_benchmark + +## Longform 500 Human Review Closeout +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_500_family +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_500_benchmark, submit_human_review_samples_for_500_windows + +## Longform 500 Ending Sign-off +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_500_family +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_500_benchmark, review_sample_coverage_500.ending_window_human_closeout_ready + +## Longform 500 Interactive Sign-off +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_500_interactive +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_500_interactive_benchmark, review_sample_coverage_500 + +## Longform 1000 Readiness +- status: watch +- ready: no +- reason: longform_1000_readiness_watch +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: longform_1000_feasibility.ready, fresh_longform_1000_diagnostics_benchmark + +## Longform 1000 Interactive Sign-off +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_1000_interactive +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_1000_interactive_benchmark, longform_1000_readiness.ready + +## Longform 1000 Human Review Closeout +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_1000_family +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_1000_diagnostics_benchmark, submit_human_review_samples_for_1000_windows +- human reviewed target count: 0 +- planned target count: 0 + +## Longform 1000 Feasibility +- status: watch +- ready: no +- reason: benchmark_mode_not_longform_1000_family +- blocking worlds: - +- watch worlds: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane +- required evidence: run_longform_1000_diagnostics_benchmark ## Ranking and Metric Delta -- strongest packs changed: entered [-] · exited [-] -- weakest packs changed: entered [-] · exited [-] -- current strongest: jade_court_exam, xianxia_forgotten_vow -- current weakest: synthetic_min_pack, jade_court_romance, urban_mystery_lotus_lane +- strongest packs changed: entered [synthetic_min_pack, tide_archive_memory_debt] · exited [jade_court_exam, xianxia_forgotten_vow] +- weakest packs changed: entered [jade_court_exam] · exited [synthetic_min_pack] +- current strongest: synthetic_min_pack, tide_archive_memory_debt +- current weakest: jade_court_exam, jade_court_romance, urban_mystery_lotus_lane - regressions: none diff --git a/tests/fixtures/quality_eval/adversarial/publish_candidate_failed_grounding.json b/tests/fixtures/quality_eval/adversarial/publish_candidate_failed_grounding.json new file mode 100644 index 0000000..f5fdb7b --- /dev/null +++ b/tests/fixtures/quality_eval/adversarial/publish_candidate_failed_grounding.json @@ -0,0 +1,38 @@ +{ + "sample_id": "publish_candidate_failed_grounding", + "scenario": "publish_candidate", + "risk_tier": "L3", + "input": { + "text": "她已经回到了从未存在过的第七王朝,并公开承认旧世界的债已经全部结束。" + }, + "context": { + "scene_beats": [ + { + "event": { + "title": "公开承认旧债", + "summary": "她承认旧债", + "scene_function": "truth_trial" + } + } + ], + "chapter_task": { + "objective": "承认旧债" + } + }, + "materials": { + "world_facts": ["旧债仍未结束"], + "world_bible": { + "theme": "旧债" + } + }, + "expected_behavior": "failed_grounding", + "expected_veto": true, + "expected_reason_codes": ["grounding_missing_support"], + "rubric_targets": { + "max_overall_score": 0.8 + }, + "grounding_expectation": { + "status": "failed", + "max_unsupported_claims": 3 + } +} diff --git a/tests/fixtures/quality_eval/boundary/reader_continue_weak_grounding.json b/tests/fixtures/quality_eval/boundary/reader_continue_weak_grounding.json new file mode 100644 index 0000000..3d4202c --- /dev/null +++ b/tests/fixtures/quality_eval/boundary/reader_continue_weak_grounding.json @@ -0,0 +1,38 @@ +{ + "sample_id": "reader_continue_weak_grounding", + "scenario": "reader_continue", + "risk_tier": "L2", + "input": { + "text": "她终于决定继续把这句真话说出来,但是另一段过去仍然压在心里。" + }, + "context": { + "scene_beats": [ + { + "event": { + "title": "她决定说出真话", + "summary": "她终于决定继续", + "scene_function": "truth_trial" + } + } + ], + "chapter_task": { + "objective": "继续说出真话" + } + }, + "materials": { + "world_facts": ["她决定继续"], + "world_bible": { + "theme": "真话" + } + }, + "expected_behavior": "weak_grounding", + "expected_veto": false, + "expected_reason_codes": ["grounding_missing_support"], + "rubric_targets": { + "min_overall_score": 0.4 + }, + "grounding_expectation": { + "status": "weak", + "max_unsupported_claims": 1 + } +} diff --git a/tests/fixtures/quality_eval/normal/reader_continue_pass.json b/tests/fixtures/quality_eval/normal/reader_continue_pass.json new file mode 100644 index 0000000..11015d1 --- /dev/null +++ b/tests/fixtures/quality_eval/normal/reader_continue_pass.json @@ -0,0 +1,42 @@ +{ + "sample_id": "reader_continue_pass", + "scenario": "reader_continue", + "risk_tier": "L2", + "input": { + "text": "她终于决定继续把这句真话说出来,回廊里的风也像跟着停了一瞬。" + }, + "context": { + "scene_beats": [ + { + "event": { + "title": "她决定说出真话", + "summary": "她终于决定继续把真话说出来", + "scene_function": "truth_trial", + "location": "回廊" + } + } + ], + "chapter_task": { + "objective": "继续说出真话", + "summary": "她决定继续把真话说出来" + } + }, + "materials": { + "world_facts": ["她决定继续把真话说出来", "回廊里的真话已经被说出"], + "world_bible": { + "theme": "真话", + "summary": "她决定继续把真话说出来", + "location": "回廊" + } + }, + "expected_behavior": "pass_or_review_required", + "expected_veto": false, + "expected_reason_codes": [], + "rubric_targets": { + "min_overall_score": 0.5 + }, + "grounding_expectation": { + "status": "passed", + "max_unsupported_claims": 0 + } +} diff --git a/tests/test_author_workflow.py b/tests/test_author_workflow.py index f78ee23..c0274b9 100644 --- a/tests/test_author_workflow.py +++ b/tests/test_author_workflow.py @@ -2,6 +2,7 @@ from src.narrativeos.api import create_app from src.narrativeos.repository import SQLAlchemyRepository +from src.narrativeos.services.author_collaboration import AuthorCollaborationService from src.narrativeos.services.authoring import AuthoringService from src.narrativeos.services.billing import BillingService @@ -185,3 +186,29 @@ def test_author_workflow_api_returns_expected_fields_and_stage_transitions(tmp_p waiting = client.get(f"/v1/author/workflow?account_id=acct_author&world_version_id={draft_id}") assert waiting.status_code == 200 assert waiting.json()["recommended_action"] == "wait_for_review" + + +def test_author_workflow_human_approval_can_clear_rewrite_advisory(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_workflow_approval_override.db")) + billing = _grant_author_access(repository, account_id="acct_author") + authoring = AuthoringService(repository, billing_service=billing) + collaboration = AuthorCollaborationService(repository) + + draft = authoring.create_draft_from_brief(_brief_payload("acct_author")) + _mark_simulation_fresh(repository, draft["world_version_id"], decision="rewrite") + requested = collaboration.request_approval( + world_version_id=draft["world_version_id"], + payload={"reviewer_id": "lead_editor", "reason": "请人工确认 rewrite advisory。"}, + ) + assert requested["approval"]["status"] == "requested" + collaboration.approval_decision( + world_version_id=draft["world_version_id"], + payload={"reviewer_id": "lead_editor", "status": "approved", "reason": "人工确认可以送审。"}, + ) + + summary = authoring.workflow_summary(account_id="acct_author", world_version_id=draft["world_version_id"]) + + assert summary["stage"] == "approved_for_submit" + assert summary["recommended_action"] == "submit" + assert summary["can_submit"] is True + assert "simulation_requires_revision" not in {item["key"] for item in summary["blockers"]} diff --git a/tests/test_beta_platform.py b/tests/test_beta_platform.py index a68121e..52c6858 100644 --- a/tests/test_beta_platform.py +++ b/tests/test_beta_platform.py @@ -95,21 +95,21 @@ def test_repo_alembic_scaffold_is_discoverable_and_stampable(tmp_path: Path): history = alembic_history() assert history["enabled"] is True - assert history["head_revision"] == "20260404_0012" + assert history["head_revision"] == "20260414_0016" assert history["history"] engine = create_engine(f"sqlite:///{tmp_path / 'alembic_lifecycle.db'}", future=True) before = inspect_alembic_state(engine) - assert before["head_revision"] == "20260404_0012" + assert before["head_revision"] == "20260414_0016" assert before["status"] == "not_stamped" stamped = stamp_alembic_head(str(engine.url)) assert stamped["enabled"] is True - assert stamped["target_revision"] == "20260404_0012" + assert stamped["target_revision"] == "20260414_0016" after = inspect_alembic_state(engine) assert after["status"] == "at_head" - assert after["current_revision"] == "20260404_0012" + assert after["current_revision"] == "20260414_0016" def test_schema_lifecycle_can_report_pending_and_apply_temp_migrations(tmp_path: Path): diff --git a/tests/test_cross_pack_benchmark.py b/tests/test_cross_pack_benchmark.py index 22a3579..1d2dc64 100644 --- a/tests/test_cross_pack_benchmark.py +++ b/tests/test_cross_pack_benchmark.py @@ -1,6 +1,21 @@ +import json +from unittest.mock import patch from typing import Optional -from src.narrativeos.benchmark.runner import BENCHMARK_PACKS, main, run_benchmark +from src.narrativeos.content_quality_strategy_execution import ( + build_strategy_bundle_batch_validation_summary, + build_strategy_bundle_batch_validation_trend, + list_strategy_bundle_batch_validation_history, + record_strategy_bundle_batch_validation_run, +) +from src.narrativeos.benchmark.runner import ( + BENCHMARK_PACKS, + _DiagnosticIssueScanCache, + _surface_issue_codes_for_payload, + main, + run_benchmark, +) +from src.narrativeos.benchmark.reporting import render_benchmark_markdown from src.narrativeos.eval.taxonomy import ISSUE_TAXONOMY from src.narrativeos.repository import SQLAlchemyRepository from src.narrativeos.worldpacks.registry import FileSystemWorldRegistry @@ -16,6 +31,7 @@ def test_registry_benchmark_worldpacks_excludes_template_assets(): "urban_mystery_lotus_lane", "xianxia_forgotten_vow", "synthetic_min_pack", + "tide_archive_memory_debt", } <= world_ids @@ -60,6 +76,12 @@ def test_cross_pack_benchmark_outputs_kernel_metrics(tmp_path): assert "strongest_packs" in report assert "weakest_packs" in report assert "weakest_pack_diagnostics" in report + assert "weakest_pack_polish_program" in report + assert "content_quality_contract_gate" in report + assert "commercial_long_route_gate" in report + assert report["commercial_long_route_gate"]["applicable"] is False + assert "strategy_validation_summary" in report + assert "content_quality_contract_summary" in report assert report["top_failing_packs"] == report["weakest_packs"] assert "delta_summary" in report assert "cross_pack_pass_rate_delta" in report["delta_summary"] @@ -72,6 +94,659 @@ def test_cross_pack_benchmark_outputs_kernel_metrics(tmp_path): assert "worst_chapters" in report["weakest_pack_diagnostics"][0] assert "attribution_map" in report["weakest_pack_diagnostics"][0] assert "next_fix_candidates" in report["weakest_pack_diagnostics"][0] + assert "stop_condition" in report["weakest_pack_diagnostics"][0] + assert "polish_bundle" in report["weakest_pack_diagnostics"][0] + assert "recommended_strategy_bundles" in report["weakest_pack_diagnostics"][0] + if report["strategy_validation_summary"]["available"]: + assert report["strategy_validation_summary"]["bundle_count"] >= 1 + assert report["weakest_pack_diagnostics"][0]["recommended_strategy_bundles"][0]["execution_protocol_enabled"] is True + else: + assert report["content_quality_contract_gate"]["ok"] is True + assert report["strategy_validation_summary"]["bundle_count"] == 0 + assert report["weakest_pack_diagnostics"][0]["recommended_strategy_bundles"] == [] + assert "content_quality_contract_window_metrics" in sample + assert "content_quality_contract_coverage" in sample + + +def test_cross_pack_benchmark_outputs_runtime_profile(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "benchmark_runtime.db")) + simulated = _simulation_report( + pass_rate=1.0, + rewrite_rate=0.0, + block_rate=0.0, + overall_scores=[0.91, 0.9], + issue_codes=[], + detail_density=0.08, + ) + simulated["chapter_trace"] = [ + { + "runtime_latency_ms": 12.0, + "lint_latency_ms": 1.5, + "evaluation_latency_ms": 2.0, + "render_timing_ms": {"write_draft": 7.0, "post_repair_lint": 1.0, "total_render_scene": 8.5}, + "quality_pass_timing_ms": {"total_ms": 5.0}, + "quality_pass_actions": ["q03_repetition_guard", "q05_detail_inline"], + }, + { + "runtime_latency_ms": 10.0, + "lint_latency_ms": 1.0, + "evaluation_latency_ms": 1.5, + "render_timing_ms": {"write_draft": 6.0, "post_repair_lint": 0.8, "total_render_scene": 7.2}, + "quality_pass_timing_ms": {"total_ms": 4.0}, + "quality_pass_actions": ["q04_exposition_guard"], + }, + ] + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack=["jade_court_exam"], + baseline=None, + simulation_runner=lambda _world_id, _world_version_id: simulated, + ) + world_profile = report["worlds"][0]["runtime_profile"] + assert world_profile["stages_ms"]["generation_runtime"] == 22.0 + assert world_profile["stages_ms"]["quality_pass"] == 9.0 + assert world_profile["stages_ms"]["lint"] == 2.5 + assert world_profile["quality_pass_stage_action_counts"]["q03_repetition"] == 1 + assert world_profile["quality_pass_stage_action_counts"]["q05_detail"] == 1 + assert report["benchmark_runtime_profile"]["stage_totals_ms"]["quality_pass"] == 9.0 + assert report["benchmark_runtime_profile"]["safe_caches"]["repetition_signal_bundle"]["enabled"] is True + markdown = render_benchmark_markdown(report) + assert "Benchmark Runtime Profile" in markdown + assert "quality-pass stage actions" in markdown + + +def test_cross_pack_benchmark_writes_progress_and_checkpoint(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "benchmark_progress.db")) + progress_path = tmp_path / "benchmark_progress.jsonl" + checkpoint_path = tmp_path / "benchmark_progress.checkpoint.json" + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack=["jade_court_exam"], + baseline=None, + simulation_runner=lambda _world_id, _world_version_id: _simulation_report( + pass_rate=1.0, + rewrite_rate=0.0, + block_rate=0.0, + overall_scores=[0.9, 0.91], + issue_codes=[], + detail_density=0.08, + ), + progress_out=progress_path, + checkpoint_out=checkpoint_path, + ) + + events = [ + json.loads(line) + for line in progress_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + assert [item["event"] for item in events if item["event"] in {"benchmark_start", "world_start", "world_complete", "benchmark_complete"}] == [ + "benchmark_start", + "world_start", + "world_complete", + "benchmark_complete", + ] + checkpoint = json.loads(checkpoint_path.read_text(encoding="utf-8")) + assert checkpoint["schema_version"] == "benchmark_checkpoint/v1" + assert checkpoint["stage"] == "complete" + assert checkpoint["completed_world_count"] == 1 + assert checkpoint["completed_worlds"][0]["world_id"] == "jade_court_exam" + assert checkpoint["diagnostic_issue_scan_cache"]["payload_policy"] == "bounded_metrics_only" + assert report["benchmark_runtime_profile"]["safe_caches"]["diagnostic_issue_scan"]["enabled"] is True + + +def test_diagnostic_issue_scan_cache_bounds_payload_and_reuses_result(): + cache = _DiagnosticIssueScanCache() + payload = { + "chapter_id": "chapter_20", + "body": "很长的可见正文" * 10000, + "summary": "不应该传入诊断扫描的长摘要" * 1000, + "issues": [], + "hard_validator_results": { + "lint_metrics": { + "repetition_score": 0.33, + "exposition_ratio": 0.2, + "concrete_detail_density": 0.09, + "dialogue_plus_action_ratio": 0.5, + "repetition_signal_bundle": { + "event_coverage_gap_score": 0.0, + "beat_coverage_gap_score": 0.0, + }, + } + }, + "scores": {"hook_quality": 0.9, "overall_score": 0.91}, + } + scanned_payloads = [] + + def fake_scan(scan_payload, *, target_chapters): + scanned_payloads.append(dict(scan_payload)) + assert "body" not in scan_payload + assert "summary" not in scan_payload + assert target_chapters == 500 + return ["Q03"] + + with patch("src.narrativeos.benchmark.runner.diagnostic_issue_codes_for_chapter_payload", side_effect=fake_scan): + assert _surface_issue_codes_for_payload(payload, target_chapters=500, diagnostic_scan_cache=cache) == ["Q03"] + assert _surface_issue_codes_for_payload(payload, target_chapters=500, diagnostic_scan_cache=cache) == ["Q03"] + + assert len(scanned_payloads) == 1 + assert cache.summary()["hits"] == 1 + assert cache.summary()["misses"] == 1 + + +def test_fast_acceptance_profile_selects_changed_and_baseline_weakest_packs(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "benchmark_fast_gate.db")) + seen_worlds: list[str] = [] + + def simulation_runner(world_id, _world_version_id): + seen_worlds.append(world_id) + return _simulation_report( + pass_rate=1.0, + rewrite_rate=0.0, + block_rate=0.0, + overall_scores=[0.9] * 3, + issue_codes=[], + detail_density=0.08, + ) + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="all", + baseline={"weakest_packs": [{"world_id": "jade_court_exam"}, {"world_id": "synthetic_min_pack"}]}, + simulation_runner=simulation_runner, + acceptance_profile="fast", + changed_worldpacks=["urban_mystery_lotus_lane"], + fast_gate_weakest_limit=1, + ) + assert set(seen_worlds) == {"jade_court_exam", "urban_mystery_lotus_lane"} + assert report["acceptance_profile"] == "fast" + assert report["benchmark_scope_complete"] is False + assert report["fast_gate"]["enabled"] is True + assert report["fast_gate"]["nightly_full_gate_required"] is True + assert set(report["benchmark_world_ids"]) == set(seen_worlds) + + +def test_commercial_long_route_gate_is_reported_in_markdown(): + markdown = render_benchmark_markdown( + { + "benchmark_mode": "long_route", + "chapter_budget": 50, + "cross_pack_pass_rate": 0.95, + "worlds": [{"world_id": "a"}], + "delta_summary": {"cross_pack_pass_rate_delta": 0.0, "regressions": []}, + "phase_a_quality_gate": { + "ok": True, + "config_version": "phase_a_quality_gate_v1", + "failed_checks": [], + "evaluated_weakest_world_ids": ["a"], + }, + "commercial_long_route_gate": { + "applicable": True, + "ok": True, + "failed_checks": [], + }, + "weakest_packs": [ + { + "world_id": "a", + "pass_rate": 0.9, + "long_route_quality": 0.82, + "mid_arc_drop": 0.03, + "completion_ratio": 1.0, + "stop_reason": "chapter_budget_reached", + "issue_mix": [ + {"issue_code": "Q03", "count": 1, "share": 0.02}, + {"issue_code": "Q09", "count": 0, "share": 0.0}, + ], + } + ], + "strongest_packs": [], + } + ) + assert "Commercial Long-Route 50 Gate" in markdown + assert "commercial_long_route_50.db" in markdown + assert "focus issues: Q03 x1" in markdown + + +def test_strategy_bundle_batch_validation_summary_decides_continue_adapt_retire(): + continue_summary = build_strategy_bundle_batch_validation_summary( + strategy_bundle_id="q03_q04_scene_dialogue_cadence_task_coupling", + strategy_bundle_label="Scene + Dialogue + Cadence + Task Coupling", + batch_execution_mode="ephemeral_copy", + benchmark_mode="standard", + chapter_budget=6, + weakest_source_world_ids=["a", "b", "c"], + compatible_world_ids=["a", "b", "c"], + skipped_worlds=[], + validated_worlds=[ + { + "world_id": "a", + "step_receipt_summary": { + "step_status_counts": {"applied": 2}, + "asset_type_counts": {"scene_blueprint": 1}, + "operation_counts": {"replace": 2}, + "applied_step_count": 2, + "applied_edit_count": 4, + }, + "step_level_apply_receipt": [{"asset_type": "scene_blueprint", "status": "applied"}], + "result_attribution": { + "overall_status": "improved", + "improved_metrics": ["avg_repetition_score"], + "regressed_metrics": [], + "flat_metrics": [], + }, + "stop_decision": {"decision": "stop"}, + "ready_for_validation": True, + }, + { + "world_id": "b", + "step_receipt_summary": { + "step_status_counts": {"applied": 1}, + "asset_type_counts": {"voice_profiles": 1}, + "operation_counts": {"expand": 1}, + "applied_step_count": 1, + "applied_edit_count": 2, + }, + "step_level_apply_receipt": [{"asset_type": "voice_profiles", "status": "applied"}], + "result_attribution": { + "overall_status": "improved", + "improved_metrics": ["dialogue_ratio"], + "regressed_metrics": [], + "flat_metrics": [], + }, + "stop_decision": {"decision": "stop"}, + "ready_for_validation": False, + }, + { + "world_id": "c", + "step_receipt_summary": { + "step_status_counts": {"applied": 1}, + "asset_type_counts": {"response_cadence_profiles": 1}, + "operation_counts": {"expand": 1}, + "applied_step_count": 1, + "applied_edit_count": 1, + }, + "step_level_apply_receipt": [{"asset_type": "response_cadence_profiles", "status": "applied"}], + "result_attribution": { + "overall_status": "improved", + "improved_metrics": ["mid_window_exposition_breach_rate"], + "regressed_metrics": [], + "flat_metrics": [], + }, + "stop_decision": {"decision": "stop"}, + "ready_for_validation": False, + }, + ], + ) + assert continue_summary["available"] is True + assert continue_summary["decision"] == "continue" + assert continue_summary["effectiveness_rate"] == 1.0 + + adapt_summary = build_strategy_bundle_batch_validation_summary( + strategy_bundle_id="q04_scene_dialogue_cadence", + strategy_bundle_label="Scene + Dialogue + Cadence", + batch_execution_mode="ephemeral_copy", + benchmark_mode="standard", + chapter_budget=6, + weakest_source_world_ids=["a", "b"], + compatible_world_ids=["a", "b"], + skipped_worlds=[], + validated_worlds=[ + { + "world_id": "a", + "step_receipt_summary": { + "step_status_counts": {"applied": 1}, + "asset_type_counts": {"scene_realization_contracts": 1}, + "operation_counts": {"replace": 1}, + "applied_step_count": 1, + "applied_edit_count": 1, + }, + "step_level_apply_receipt": [{"asset_type": "scene_realization_contracts", "status": "applied"}], + "result_attribution": { + "overall_status": "improved", + "improved_metrics": ["dialogue_ratio"], + "regressed_metrics": [], + "flat_metrics": [], + }, + "stop_decision": {"decision": "stop"}, + "ready_for_validation": False, + }, + { + "world_id": "b", + "step_receipt_summary": { + "step_status_counts": {"skipped": 1}, + "asset_type_counts": {"emotion_action_policies": 1}, + "operation_counts": {"replace": 1}, + "applied_step_count": 0, + "applied_edit_count": 0, + }, + "step_level_apply_receipt": [{"asset_type": "emotion_action_policies", "status": "skipped"}], + "result_attribution": { + "overall_status": "flat", + "improved_metrics": [], + "regressed_metrics": [], + "flat_metrics": ["avg_exposition_ratio"], + }, + "stop_decision": {"decision": "continue"}, + "ready_for_validation": False, + }, + ], + ) + assert adapt_summary["decision"] == "adapt" + assert any(item["kind"] == "asset_step" for item in adapt_summary["adaptation_targets"]) + + retire_summary = build_strategy_bundle_batch_validation_summary( + strategy_bundle_id="q09_continuation_runway", + strategy_bundle_label="Continuation Runway", + batch_execution_mode="ephemeral_copy", + benchmark_mode="longform_100", + chapter_budget=100, + weakest_source_world_ids=["a", "b"], + compatible_world_ids=["a", "b"], + skipped_worlds=[], + validated_worlds=[ + { + "world_id": "a", + "step_receipt_summary": {"step_status_counts": {"applied": 1}, "asset_type_counts": {}, "operation_counts": {}, "applied_step_count": 1, "applied_edit_count": 1}, + "step_level_apply_receipt": [{"asset_type": "chapter_task", "status": "applied"}], + "result_attribution": { + "overall_status": "regressed", + "improved_metrics": [], + "regressed_metrics": ["late_window_q09_breach_rate"], + "flat_metrics": [], + }, + "stop_decision": {"decision": "escalate"}, + "ready_for_validation": False, + }, + { + "world_id": "b", + "step_receipt_summary": {"step_status_counts": {"applied": 1}, "asset_type_counts": {}, "operation_counts": {}, "applied_step_count": 1, "applied_edit_count": 1}, + "step_level_apply_receipt": [{"asset_type": "arc_plan", "status": "applied"}], + "result_attribution": { + "overall_status": "regressed", + "improved_metrics": [], + "regressed_metrics": ["q09_incidence_rate"], + "flat_metrics": [], + }, + "stop_decision": {"decision": "escalate"}, + "ready_for_validation": False, + }, + ], + ) + assert retire_summary["decision"] == "retire" + + empty_summary = build_strategy_bundle_batch_validation_summary( + strategy_bundle_id="q03_scene_dialogue_cadence", + strategy_bundle_label="Scene + Dialogue + Cadence", + batch_execution_mode="ephemeral_copy", + benchmark_mode="standard", + chapter_budget=6, + weakest_source_world_ids=["a"], + compatible_world_ids=[], + skipped_worlds=[{"world_id": "a", "reason": "bundle_not_recommended_for_world"}], + validated_worlds=[], + ) + assert empty_summary["available"] is False + assert empty_summary["decision"] == "" + assert empty_summary["decision_reason"] == "no_compatible_weakest_packs" + + +def test_strategy_bundle_batch_validation_history_persists_and_builds_trend(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "strategy_bundle_history.db")) + record_strategy_bundle_batch_validation_run( + repository=repository, + batch_validation={ + "generated_at": "2026-04-13T10:00:00+00:00", + "strategy_bundle_id": "q03_scene_dialogue_cadence", + "strategy_bundle_label": "Scene + Dialogue + Cadence", + "benchmark_mode": "standard", + "chapter_budget": 6, + "weakest_source_world_ids": ["jade_court_exam"], + "compatible_world_ids": ["jade_court_exam"], + "validated_world_count": 1, + "effectiveness_rate": 0.25, + "decision": "adapt", + "decision_reason": "bundle_mixed_signal_requires_adjustment", + "aggregated_result_attribution": {"overall_status_counts": {"flat": 1}, "stop_decision_counts": {"continue": 1}}, + "adaptation_targets": [{"kind": "metric", "name": "avg_exposition_ratio", "count": 1}], + }, + ) + record_strategy_bundle_batch_validation_run( + repository=repository, + batch_validation={ + "generated_at": "2026-04-13T11:00:00+00:00", + "strategy_bundle_id": "q03_scene_dialogue_cadence", + "strategy_bundle_label": "Scene + Dialogue + Cadence", + "benchmark_mode": "standard", + "chapter_budget": 6, + "weakest_source_world_ids": ["jade_court_exam"], + "compatible_world_ids": ["jade_court_exam"], + "validated_world_count": 1, + "effectiveness_rate": 0.45, + "decision": "continue", + "decision_reason": "bundle_effective_across_weakest_packs", + "aggregated_result_attribution": {"overall_status_counts": {"improved": 1}, "stop_decision_counts": {"stop": 1}}, + "adaptation_targets": [], + }, + ) + history = list_strategy_bundle_batch_validation_history( + repository=repository, + strategy_bundle_id="q03_scene_dialogue_cadence", + limit=5, + ) + trend = build_strategy_bundle_batch_validation_trend(history) + assert history["available"] is True + assert history["entry_count"] == 2 + assert history["entries"][0]["decision"] == "continue" + assert trend["trend_status"] == "improving" + assert trend["delta_effectiveness_rate"] == 0.2 + assert trend["retire_recommended"] is False + + +def test_strategy_bundle_batch_validation_trend_flags_retire_watch(): + trend = build_strategy_bundle_batch_validation_trend( + { + "available": True, + "strategy_bundle_id": "q09_continuation_runway", + "entry_count": 2, + "entries": [ + { + "generated_at": "2026-04-13T11:00:00+00:00", + "strategy_bundle_id": "q09_continuation_runway", + "decision": "retire", + "effectiveness_rate": 0.1, + }, + { + "generated_at": "2026-04-13T10:00:00+00:00", + "strategy_bundle_id": "q09_continuation_runway", + "decision": "retire", + "effectiveness_rate": 0.12, + }, + ], + } + ) + assert trend["trend_status"] == "retire_watch" + assert trend["retire_recommended"] is True + + +def test_run_benchmark_exposes_strategy_bundle_batch_validation_when_enabled(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "benchmark_batch_validation.db")) + with patch( + "src.narrativeos.benchmark.runner._validate_strategy_bundle_batch", + return_value={ + "available": True, + "strategy_bundle_id": "q03_scene_dialogue_cadence", + "strategy_bundle_label": "Scene + Dialogue + Cadence", + "batch_execution_mode": "ephemeral_copy", + "benchmark_mode": "standard", + "chapter_budget": 6, + "weakest_source_world_ids": ["jade_court_exam"], + "compatible_world_ids": ["jade_court_exam"], + "skipped_worlds": [], + "validated_world_count": 1, + "validated_worlds": [], + "aggregated_step_receipts": {}, + "aggregated_result_attribution": {}, + "effectiveness_rate": 1.0, + "decision": "continue", + "decision_reason": "bundle_effective_across_weakest_packs", + "adaptation_targets": [], + }, + ) as validator: + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack=["jade_court_exam", "synthetic_min_pack"], + baseline=None, + simulation_runner=lambda _world_id, _world_version_id: _simulation_report( + pass_rate=0.8, + rewrite_rate=0.2, + block_rate=0.0, + overall_scores=[0.82] * 6, + issue_codes=["Q03"], + detail_density=0.012, + ), + validate_strategy_bundle=True, + strategy_bundle_id="q03_scene_dialogue_cadence", + weakest_limit=2, + ) + assert validator.called is True + assert report["strategy_bundle_batch_validation"]["available"] is True + assert report["strategy_bundle_batch_validation"]["strategy_bundle_id"] == "q03_scene_dialogue_cadence" + markdown = render_benchmark_markdown(report) + assert "Strategy Bundle Batch Validation" in markdown + assert "bundle_effective_across_weakest_packs" in markdown + + +def test_run_benchmark_persists_strategy_bundle_batch_validation_history_when_enabled(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "benchmark_batch_history_persist.db")) + with patch( + "src.narrativeos.benchmark.runner._validate_strategy_bundle_batch", + return_value={ + "available": True, + "generated_at": "2026-04-13T12:00:00+00:00", + "strategy_bundle_id": "q03_scene_dialogue_cadence", + "strategy_bundle_label": "Scene + Dialogue + Cadence", + "batch_execution_mode": "ephemeral_copy", + "benchmark_mode": "standard", + "chapter_budget": 6, + "weakest_source_world_ids": ["jade_court_exam"], + "compatible_world_ids": ["jade_court_exam"], + "skipped_worlds": [], + "validated_world_count": 1, + "validated_worlds": [], + "aggregated_step_receipts": {}, + "aggregated_result_attribution": {"overall_status_counts": {"improved": 1}, "stop_decision_counts": {"stop": 1}}, + "effectiveness_rate": 0.8, + "decision": "continue", + "decision_reason": "bundle_effective_across_weakest_packs", + "adaptation_targets": [], + }, + ): + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack=["jade_court_exam", "synthetic_min_pack"], + baseline=None, + simulation_runner=lambda _world_id, _world_version_id: _simulation_report( + pass_rate=0.8, + rewrite_rate=0.2, + block_rate=0.0, + overall_scores=[0.82] * 6, + issue_codes=["Q03"], + detail_density=0.012, + ), + validate_strategy_bundle=True, + strategy_bundle_id="q03_scene_dialogue_cadence", + weakest_limit=2, + ) + saved = repository.list_review_records( + asset_type="strategy_bundle_batch_validation", + asset_id="q03_scene_dialogue_cadence", + ) + assert len(saved) == 1 + assert saved[0]["status"] == "continue" + assert report["strategy_bundle_batch_validation_history"]["available"] is True + assert report["strategy_bundle_batch_validation_trend"]["trend_status"] == "insufficient_history" + + +def test_run_benchmark_can_read_strategy_bundle_history_without_new_rerun(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "benchmark_batch_history_read.db")) + record_strategy_bundle_batch_validation_run( + repository=repository, + batch_validation={ + "generated_at": "2026-04-13T09:00:00+00:00", + "strategy_bundle_id": "q03_scene_dialogue_cadence", + "strategy_bundle_label": "Scene + Dialogue + Cadence", + "benchmark_mode": "standard", + "chapter_budget": 6, + "weakest_source_world_ids": ["jade_court_exam"], + "compatible_world_ids": ["jade_court_exam"], + "validated_world_count": 1, + "effectiveness_rate": 0.55, + "decision": "adapt", + "decision_reason": "bundle_mixed_signal_requires_adjustment", + "aggregated_result_attribution": {"overall_status_counts": {"flat": 1}, "stop_decision_counts": {"continue": 1}}, + "adaptation_targets": [{"kind": "metric", "name": "avg_exposition_ratio", "count": 1}], + }, + ) + with patch("src.narrativeos.benchmark.runner._validate_strategy_bundle_batch") as validator: + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack=["jade_court_exam", "synthetic_min_pack"], + baseline=None, + simulation_runner=lambda _world_id, _world_version_id: _simulation_report( + pass_rate=0.8, + rewrite_rate=0.2, + block_rate=0.0, + overall_scores=[0.82] * 6, + issue_codes=["Q03"], + detail_density=0.012, + ), + validate_strategy_bundle=False, + strategy_bundle_id="q03_scene_dialogue_cadence", + weakest_limit=2, + ) + assert validator.called is False + assert report["strategy_bundle_batch_validation"]["decision_reason"] == "history_only_query" + assert report["strategy_bundle_batch_validation_history"]["entry_count"] == 1 + assert report["strategy_bundle_batch_validation_trend"]["trend_status"] == "insufficient_history" + + +def test_render_benchmark_markdown_shows_batch_validation_placeholder_when_unavailable(): + markdown = render_benchmark_markdown( + { + "cross_pack_pass_rate": 0.0, + "worlds": [], + "strongest_packs": [], + "weakest_packs": [], + "weakest_pack_diagnostics": [], + "weakest_pack_polish_program": {}, + "delta_summary": {}, + "strategy_bundle_batch_validation": { + "available": False, + "strategy_bundle_id": "q03_scene_dialogue_cadence", + "strategy_bundle_label": "Scene + Dialogue + Cadence", + "batch_execution_mode": "ephemeral_copy", + "weakest_source_world_ids": ["jade_court_exam"], + "compatible_world_ids": [], + "skipped_worlds": [{"world_id": "jade_court_exam", "reason": "bundle_not_recommended_for_world"}], + "validated_world_count": 0, + "aggregated_result_attribution": {}, + "effectiveness_rate": 0.0, + "decision": "", + "decision_reason": "no_compatible_weakest_packs", + "adaptation_targets": [], + }, + } + ) + assert "Strategy Bundle Batch Validation" in markdown + assert "status: not_run" in markdown + assert "no_compatible_weakest_packs" in markdown def test_cross_pack_benchmark_lifts_weakest_packs_above_zero(tmp_path): @@ -163,6 +838,8 @@ def _simulation_report( exposition_ratio: float = 0.3, dialogue_ratio: float = 0.38, hook_quality_sequence: Optional[list[float]] = None, + interactive_summary: Optional[dict[str, object]] = None, + post_steer_issue_window_summary: Optional[list[dict[str, object]]] = None, ) -> dict[str, object]: issue_count = len(overall_scores) if issue_codes else 0 top_issue_categories = [ @@ -174,7 +851,7 @@ def _simulation_report( } for code in issue_codes ] - return { + report = { "evaluation_summary": { "pass_rate": pass_rate, "rewrite_rate": rewrite_rate, @@ -210,11 +887,15 @@ def _simulation_report( for index, score in enumerate(overall_scores, start=1) ], } + if interactive_summary is not None: + report["interactive_summary"] = interactive_summary + if post_steer_issue_window_summary is not None: + report["post_steer_issue_window_summary"] = post_steer_issue_window_summary + return report def test_cross_pack_benchmark_composite_ranking_and_delta_changes(tmp_path): repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "benchmark_delta.db")) - target_worlds = list(BENCHMARK_PACKS) baseline_reports = { "jade_court_exam": _simulation_report( pass_rate=1.0, @@ -257,6 +938,7 @@ def test_cross_pack_benchmark_composite_ranking_and_delta_changes(tmp_path): detail_density=0.004, ), } + target_worlds = list(baseline_reports) baseline = run_benchmark( repository=repository, golden_dir=tmp_path / "goldens", @@ -322,6 +1004,10 @@ def test_cross_pack_benchmark_composite_ranking_and_delta_changes(tmp_path): assert report["weakest_pack_diagnostics"][0]["worst_chapters"][0]["chapter_id"] == "chapter_3" assert report["weakest_pack_diagnostics"][0]["attribution_map"]["modules"][0]["module"] == "planner" assert report["weakest_pack_diagnostics"][0]["next_fix_candidates"][0]["asset"] == "scene_blueprints" + assert report["weakest_pack_diagnostics"][0]["stop_condition"]["status"] == "continue_polish" + assert report["weakest_pack_polish_program"]["status"] == "continue_polish" + assert "phase_a_quality_gate" in report + assert report["phase_a_quality_gate"]["config_version"] == "phase_a_quality_gate_v1" markdown = main( [ "--worldpack", @@ -342,6 +1028,9 @@ def test_cross_pack_benchmark_composite_ranking_and_delta_changes(tmp_path): assert "Strongest Packs" in markdown_text assert "Weakest Packs" in markdown_text assert "Weakest Pack Diagnostics" in markdown_text + assert "Weakest Pack Polish Program" in markdown_text + assert "Phase A Quality Gate" in markdown_text + assert "Longform L1 Sign-off" in markdown_text assert "issue mix" in markdown_text @@ -426,3 +1115,110 @@ def test_long_route_benchmark_outputs_route_level_metrics(tmp_path): assert "benchmark mode: long_route" in markdown_text assert "Long-Route Summary" in markdown_text assert "target chapters: 36" in markdown_text + assert "q03 calibration recommendations" in markdown_text + assert "q09 calibration recommendations" in markdown_text + + +def test_long_route_benchmark_supports_strong_interactive_profile(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "interactive_long_route.db")) + target_worlds = ["jade_court_exam", "synthetic_min_pack"] + seen_scenarios: dict[str, list[dict[str, object]]] = {} + + def simulation_runner(world_id, _world_version_id, interactive_scenarios): + seen_scenarios[world_id] = [dict(item) for item in interactive_scenarios] + return _simulation_report( + pass_rate=0.95, + rewrite_rate=0.05, + block_rate=0.0, + overall_scores=[0.89] * 200, + issue_codes=["Q03"], + detail_density=0.024, + chapter_budget=200, + interactive_summary={ + "scenario_count": len(interactive_scenarios), + "steering_recovery_rate": 1.0, + "post_steer_route_survival": 0.92, + "memory_consistency_after_steer": 0.88, + "promise_reconciliation_after_steer": 0.84, + "replan_stability_score": 0.9, + }, + post_steer_issue_window_summary=[ + { + "scenario_id": f"scenario_{index}", + "scenario_kind": str(scenario.get("scenario_kind") or ""), + "chapter_index": int(scenario.get("trigger_chapter", 0) or 0), + "short_window": { + "chapter_count": 3, + "issue_counts": {"Q03": 1, "Q04": 0, "Q05": 0, "Q09": 0}, + "issue_rates": {"Q03": 0.333, "Q04": 0.0, "Q05": 0.0, "Q09": 0.0}, + }, + "long_window": { + "chapter_count": 10, + "issue_counts": {"Q03": 2, "Q04": 1, "Q05": 0, "Q09": 1}, + "issue_rates": {"Q03": 0.2, "Q04": 0.1, "Q05": 0.0, "Q09": 0.1}, + }, + } + for index, scenario in enumerate(interactive_scenarios, start=1) + ], + ) + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack=target_worlds, + baseline=None, + simulation_runner=simulation_runner, + benchmark_mode="long_route", + max_chapters=200, + interactive_profile="strong", + ) + + assert report["benchmark_mode"] == "long_route" + assert report["interactive_profile"] == "strong" + assert report["content_quality_contract_summary"]["band"] == "200" + assert report["content_quality_contract_summary"]["gate_enforced"] is False + assert report["interactive_long_route_summary"]["scenario_count"] == 5 + assert report["interactive_long_route_summary"]["avg_short_window_issue_rates"]["Q03"] == 0.333 + assert report["interactive_long_route_summary"]["avg_long_window_issue_rates"]["Q09"] == 0.1 + assert [item["trigger_chapter"] for item in seen_scenarios["jade_court_exam"]] == [20, 60, 100, 140, 180] + + exam = next(item for item in report["worlds"] if item["world_id"] == "jade_court_exam") + assert exam["interactive_summary"]["scenario_count"] == 5 + assert len(exam["post_steer_issue_window_summary"]) == 5 + + markdown = render_benchmark_markdown(report) + assert "Interactive Long-Route Summary" in markdown + assert "Content Quality Contract Summary" in markdown + assert "band: 200" in markdown + assert "profile: strong" in markdown + assert "Post-Steer Issue Windows" in markdown + + +def test_200_diagnostic_surface_exposes_q05_detail_breaches(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "diagnostic_q05.db")) + target_worlds = ["synthetic_min_pack"] + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack=target_worlds, + baseline=None, + benchmark_mode="long_route", + max_chapters=200, + simulation_runner=lambda _world_id, _world_version_id: _simulation_report( + pass_rate=0.9, + rewrite_rate=0.1, + block_rate=0.0, + overall_scores=[0.82] * 200, + issue_codes=[], + detail_density=0.001, + dialogue_ratio=0.25, + exposition_ratio=0.55, + chapter_budget=200, + ), + ) + world = report["worlds"][0] + issue_codes = [item["issue_code"] for item in world["issue_mix"]] + assert "Q05" in issue_codes + assert report["content_quality_contract_summary"]["band"] == "200" + diagnostic = report["weakest_pack_diagnostics"][0] + assert any("Q05" in item.get("issue_codes", []) for item in diagnostic["window_breach_attribution"]) diff --git a/tests/test_cross_pack_merge_gate.py b/tests/test_cross_pack_merge_gate.py index 2034511..8539183 100644 --- a/tests/test_cross_pack_merge_gate.py +++ b/tests/test_cross_pack_merge_gate.py @@ -1,4 +1,5 @@ import json +from datetime import datetime, timedelta, timezone from pathlib import Path import pytest @@ -9,8 +10,7 @@ validate_benchmark_report, validate_pr_evidence, ) -from src.narrativeos.benchmark.runner import run_benchmark -from src.narrativeos.repository import SQLAlchemyRepository +from src.narrativeos.benchmark.release_quality_gate import evaluate_release_quality_gate def _sample_pr_body() -> str: @@ -38,9 +38,36 @@ def _sample_pr_body() -> str: """ -def test_validate_benchmark_report_accepts_current_shape(tmp_path): - repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "merge_gate.db")) - report = run_benchmark(repository=repository, golden_dir=tmp_path / "goldens", baseline={"worlds": [], "cross_pack_pass_rate": 0.0}) +def test_validate_benchmark_report_accepts_current_shape(): + report = { + "cross_pack_pass_rate": 0.93, + "strongest_packs": [{"world_id": "xianxia_forgotten_vow"}], + "weakest_packs": [ + { + "world_id": "jade_court_romance", + "pass_rate": 0.61, + "issue_mix": [ + {"issue_code": "Q03", "share": 0.2}, + {"issue_code": "Q05", "share": 0.24}, + ], + } + ], + "top_failing_packs": [ + { + "world_id": "jade_court_romance", + "pass_rate": 0.61, + "issue_mix": [ + {"issue_code": "Q03", "share": 0.2}, + {"issue_code": "Q05", "share": 0.24}, + ], + } + ], + "delta_summary": { + "cross_pack_pass_rate_delta": 0.0, + "regressions": [], + "ranking_changes": {}, + }, + } assert validate_benchmark_report(report) == [] @@ -61,6 +88,165 @@ def test_validate_benchmark_report_rejects_regression(): assert "metric_regression_detected" in errors +def test_release_quality_gate_uses_shared_phase_a_thresholds(): + gate = evaluate_release_quality_gate( + { + "cross_pack_pass_rate": 0.91, + "weakest_packs": [ + { + "world_id": "jade_court_romance", + "pass_rate": 0.62, + "issue_mix": [ + {"issue_code": "Q03", "share": 0.22}, + {"issue_code": "Q05", "share": 0.28}, + ], + } + ], + } + ) + assert gate["ok"] is True + assert gate["config_version"] == "phase_a_quality_gate_v1" + + +def test_release_quality_gate_blocks_when_shared_thresholds_are_missed(): + gate = evaluate_release_quality_gate( + { + "cross_pack_pass_rate": 0.85, + "weakest_packs": [ + { + "world_id": "jade_court_romance", + "pass_rate": 0.5, + "issue_mix": [ + {"issue_code": "Q03", "share": 0.4}, + {"issue_code": "Q09", "share": 0.25}, + ], + } + ], + } + ) + assert gate["ok"] is False + assert "phase_a_cross_pack_pass_rate_below_min" in gate["failed_checks"] + assert "phase_a_weakest_pack_pass_rate_below_min" in gate["failed_checks"] + assert "phase_a_q03_weakest_issue_share_exceeded" in gate["failed_checks"] + assert "phase_a_q09_weakest_issue_share_exceeded" in gate["failed_checks"] + + +def test_release_quality_gate_blocks_commercial_long_route_weakest_pack_collapse(): + gate = evaluate_release_quality_gate( + { + "benchmark_mode": "long_route", + "chapter_budget": 50, + "benchmark_scope_complete": True, + "cross_pack_pass_rate": 0.95, + "weakest_packs": [ + { + "world_id": "urban_mystery_lotus_lane", + "pass_rate": 0.72, + "long_route_quality": 0.42, + "completion_ratio": 0.74, + "mid_arc_drop": 0.42, + "stop_reason": "quality_collapse", + "issue_mix": [ + {"issue_code": "Q03", "share": 0.2}, + {"issue_code": "Q04", "share": 0.18}, + {"issue_code": "Q05", "share": 0.22}, + {"issue_code": "Q09", "share": 0.18}, + ], + } + ], + } + ) + assert gate["ok"] is False + assert "commercial_long_route_readability_below_min" in gate["failed_checks"] + + +def test_release_quality_gate_skips_commercial_long_route_for_short_benchmark(): + gate = evaluate_release_quality_gate( + { + "benchmark_mode": "standard", + "chapter_budget": 6, + "cross_pack_pass_rate": 0.95, + "weakest_packs": [ + { + "world_id": "synthetic_min_pack", + "pass_rate": 0.72, + "issue_mix": [{"issue_code": "Q03", "share": 0.2}], + } + ], + } + ) + assert gate["ok"] is True + assert not any(str(item).startswith("commercial_long_route") for item in gate["failed_checks"]) + + +def test_release_quality_gate_accepts_empty_commercial_issue_mix_as_clean_evidence(): + gate = evaluate_release_quality_gate( + { + "benchmark_mode": "long_route", + "chapter_budget": 50, + "benchmark_scope_complete": True, + "cross_pack_pass_rate": 0.95, + "weakest_packs": [ + { + "world_id": "jade_court_exam", + "pass_rate": 1.0, + "long_route_quality": 0.9, + "completion_ratio": 1.0, + "mid_arc_drop": 0.0, + "stop_reason": "chapter_budget_reached", + "issue_mix": [], + } + ], + } + ) + assert gate["ok"] is True + assert "commercial_long_route_weakest_evidence_missing" not in gate["failed_checks"] + + +def test_validate_benchmark_report_rejects_blocked_longform_l1_signoff(): + report = { + "benchmark_mode": "longform_100", + "cross_pack_pass_rate": 1.0, + "strongest_packs": [{"world_id": "a"}], + "weakest_packs": [{"world_id": "b"}], + "top_failing_packs": [{"world_id": "b"}], + "delta_summary": { + "cross_pack_pass_rate_delta": 0.0, + "regressions": [], + "ranking_changes": {}, + }, + "longform_l1_signoff": { + "status": "blocked", + "blocking_worlds": ["b"], + }, + } + errors = validate_benchmark_report(report) + assert "longform_l1_signoff_blocked" in errors + + +def test_validate_benchmark_report_rejects_stale_longform_l1_signoff(): + report = { + "benchmark_mode": "longform_100", + "cross_pack_pass_rate": 1.0, + "strongest_packs": [{"world_id": "a"}], + "weakest_packs": [{"world_id": "b"}], + "top_failing_packs": [{"world_id": "b"}], + "delta_summary": { + "cross_pack_pass_rate_delta": 0.0, + "regressions": [], + "ranking_changes": {}, + }, + "longform_l1_signoff": { + "status": "watch", + "reason": "benchmark_signoff_stale", + "generated_at": (datetime.now(timezone.utc) - timedelta(days=8)).isoformat(), + "blocking_worlds": [], + }, + } + errors = validate_benchmark_report(report) + assert "longform_l1_signoff_blocked" in errors + + def test_validate_pr_evidence_requires_delta_fields(): errors = validate_pr_evidence("## PR summary\n- Lane: Lane A\n") assert "missing_pr_field:Goal met" in errors @@ -96,6 +282,10 @@ def test_run_merge_gate_checks_pr_body_and_writes_summary(tmp_path): "current_weakest": ["jade_court_romance"], }, }, + "longform_l1_signoff": { + "status": "watch", + "blocking_worlds": [], + }, } ), encoding="utf-8", @@ -113,6 +303,8 @@ def test_run_merge_gate_checks_pr_body_and_writes_summary(tmp_path): summary_text = summary_path.read_text(encoding="utf-8") assert "Cross-Pack Merge Gate" in summary_text assert "strongest packs: xianxia_forgotten_vow" in summary_text + assert "longform_l1_signoff: watch" in summary_text + assert "phase_a_quality_gate" in summary_text def test_run_merge_gate_fails_when_pr_body_missing(tmp_path): diff --git a/tests/test_deepseek_renderer_shadow_eval.py b/tests/test_deepseek_renderer_shadow_eval.py new file mode 100644 index 0000000..f64e713 --- /dev/null +++ b/tests/test_deepseek_renderer_shadow_eval.py @@ -0,0 +1,157 @@ +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest + +from src.narrativeos.providers import InlineJSONLLMBackend + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = ROOT / "scripts" / "run_deepseek_renderer_shadow_eval.py" + + +def _load_script_module(): + spec = spec_from_file_location("deepseek_renderer_shadow_eval", SCRIPT_PATH) + assert spec and spec.loader + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_shadow_eval_builds_issue_delta_summary_for_target_codes(): + module = _load_script_module() + summary = module.build_issue_delta_summary( + baseline={ + "worlds": [ + { + "world_id": "jade_court_romance", + "route_longevity": 3, + "pass_rate": 0.667, + "top_issue_categories": [ + {"issue_code": "Q04", "count": 2}, + {"issue_code": "Q05", "count": 1}, + ], + } + ] + }, + current={ + "worlds": [ + { + "world_id": "jade_court_romance", + "route_longevity": 4, + "pass_rate": 1.0, + "top_issue_categories": [ + {"issue_code": "Q03", "count": 1}, + {"issue_code": "Q05", "count": 0}, + ], + } + ] + }, + target_worlds=["jade_court_romance"], + ) + + aggregate = summary["aggregate"] + assert aggregate["Q04"]["count_delta"] == -2 + assert aggregate["Q05"]["count_delta"] == -1 + assert aggregate["Q03"]["count_delta"] == 1 + assert summary["world_deltas"][0]["after_pass_rate"] == 1.0 + + +def test_shadow_eval_receipt_metrics_track_fallback_and_length_retry(): + module = _load_script_module() + receipts = [ + { + "world_id": "jade_court_romance", + "renderer_selected_provider": "deepseek", + "fallback_used": False, + "renderer_attempt_count": 1, + "renderer_latency_ms": 1200.0, + }, + { + "world_id": "jade_court_romance", + "renderer_selected_provider": "deepseek", + "fallback_used": True, + "renderer_fallback_reason": "invalid_llm_payload", + "renderer_attempt_count": 2, + "llm_payload_gate": {"ok": False}, + "renderer_latency_ms": 2400.0, + }, + { + "world_id": "synthetic_min_pack", + "renderer_selected_provider": "local_rule_based", + "fallback_used": True, + "renderer_fallback_reason": "llm_backend_error", + "renderer_attempt_count": 1, + }, + ] + + metrics = module.build_receipt_metrics( + receipts=receipts, + target_worlds=["jade_court_romance", "synthetic_min_pack"], + ) + + assert metrics["global"]["receipt_count"] == 3 + assert metrics["global"]["deepseek_selected_count"] == 2 + assert metrics["global"]["fallback_rate"] == 0.667 + assert metrics["global"]["length_retry_rate"] == 0.333 + assert metrics["by_world"]["jade_court_romance"]["length_retry_rate"] == 0.5 + assert metrics["global"]["renderer_fallback_reasons"]["invalid_llm_payload"] == 1 + + +def test_shadow_eval_provider_routing_keeps_candidate_static(): + module = _load_script_module() + backend = InlineJSONLLMBackend({"concise_summary": "", "interactive_scene": "", "premium_prose": ""}) + routing = module.build_shadow_provider_routing(backend) + + candidate_decision = routing.resolve_track(track="candidate", surface="shadow_eval") + renderer_decision = routing.resolve_track(track="renderer", surface="shadow_eval") + + assert routing.candidate_backend is None + assert routing.renderer_backend is backend + assert candidate_decision["enabled"] is False + assert candidate_decision["fallback_only"] is True + assert renderer_decision["enabled"] is True + + +def test_shadow_eval_combined_report_writes_json_and_markdown(tmp_path): + module = _load_script_module() + model_report = { + "model": "deepseek-v4-flash", + "receipt_metrics": { + "global": { + "receipt_count": 2, + "deepseek_selected_count": 2, + "fallback_rate": 0.0, + "length_retry_rate": 0.0, + "renderer_latency": {"avg_latency_ms": 1000.0}, + }, + "by_world": {"jade_court_romance": {"receipt_count": 2, "fallback_rate": 0.0, "length_retry_rate": 0.0}}, + }, + "issue_delta_summary": { + "aggregate": { + "Q03": {"before_count": 0, "after_count": 0, "count_delta": 0, "before_rate": 0.0, "after_rate": 0.0, "rate_delta": 0.0}, + "Q04": {"before_count": 2, "after_count": 1, "count_delta": -1, "before_rate": 0.667, "after_rate": 0.25, "rate_delta": -0.417}, + "Q05": {"before_count": 1, "after_count": 0, "count_delta": -1, "before_rate": 0.333, "after_rate": 0.0, "rate_delta": -0.333}, + "Q09": {"before_count": 0, "after_count": 0, "count_delta": 0, "before_rate": 0.0, "after_rate": 0.0, "rate_delta": 0.0}, + } + }, + } + + report = module.build_combined_report( + model_reports=[model_report], + target_worlds=["jade_court_romance"], + max_chapters=6, + output_dir=tmp_path, + ) + + assert report["recommendation"] == "eligible_for_canary_review_after_human_sample" + assert (tmp_path / "deepseek_v4_renderer_shadow_eval.json").exists() + assert (tmp_path / "deepseek_v4_renderer_shadow_eval.md").read_text(encoding="utf-8").startswith("# DeepSeek") + + +def test_shadow_eval_fails_fast_without_api_key(monkeypatch, tmp_path): + module = _load_script_module() + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + + with pytest.raises(SystemExit, match="DEEPSEEK_API_KEY"): + module.main(["--output-dir", str(tmp_path)]) diff --git a/tests/test_dialogue_realism.py b/tests/test_dialogue_realism.py index 6777f27..b0b848b 100644 --- a/tests/test_dialogue_realism.py +++ b/tests/test_dialogue_realism.py @@ -1,8 +1,25 @@ -from src.narrativeos.core.dialogue import compose_dialogue +from src.narrativeos.core.dialogue import compose_dialogue, compose_late_longform_compact_exchange +from src.narrativeos.core.linter import lint_chapter_draft +from src.narrativeos.core.scene_realizer import realize_hook, realize_scene_opening from src.narrativeos.core.voice import response_profile_for_actor, voice_profile_for_actor +from src.narrativeos.models import ChapterPlan, SceneIntent +from src.narrativeos.rendering import _chapter_summary, _reader_chapter_title, _reader_pull_quote, _reader_story_beats from src.narrativeos.worldpacks.registry import FileSystemWorldRegistry +def _voice_separation_score(profiles): + bluntness_values = [float(item.get("bluntness", 0.5)) for item in profiles.values()] + restraint_values = [float(item.get("restraint", 0.5)) for item in profiles.values()] + return min( + 1.0, + ( + (max(bluntness_values) - min(bluntness_values)) + + (max(restraint_values) - min(restraint_values)) + ) + / 2.0, + ) + + def test_turn_taking_dialogue_structure_exists(): registry = FileSystemWorldRegistry() runtime = registry.get_runtime_bundle("jade_court_exam@1.0.0") @@ -10,7 +27,7 @@ def test_turn_taking_dialogue_structure_exists(): scene_beat = type("Beat", (), {"event": beat, "dramatic_job": "entry"})() text = compose_dialogue(runtime.world_record.world, runtime.initial_state, scene_beat, repeated=False) assert ":“" in text - assert "最后只回了一句" in text + assert text.count("”") >= 2 def test_voice_profiles_differ_across_roles(): @@ -26,3 +43,279 @@ def test_voice_profiles_differ_across_roles(): lead_response = response_profile_for_actor(runtime.world_record.world, runtime.initial_state, runtime.event_atoms[0].actors[0]) counterpart_response = response_profile_for_actor(runtime.world_record.world, runtime.initial_state, runtime.event_atoms[0].actors[1]) assert lead_response.reply_lines != counterpart_response.reply_lines + + +def test_weakest_pack_voice_assets_are_diversified(): + registry = FileSystemWorldRegistry() + for world_version_id in [ + "urban_mystery_lotus_lane@0.1.0", + "jade_court_romance@1.0.0", + "synthetic_min_pack@0.1.0", + ]: + runtime = registry.get_runtime_bundle(world_version_id) + profiles = runtime.worldpack.voice_profiles + assert profiles + bluntness_values = [float(item.get("bluntness", 0.5)) for item in profiles.values()] + restraint_values = [float(item.get("restraint", 0.5)) for item in profiles.values()] + assert max(bluntness_values) - min(bluntness_values) >= 0.35 + assert max(restraint_values) - min(restraint_values) >= 0.25 + for payload in profiles.values(): + assert len(payload.get("opening_style", [])) >= 3 + assert len(payload.get("pressure_style", [])) >= 3 + assert len(payload.get("pivot_style", [])) >= 3 + + +def test_jade_voice_separation_reaches_longform_polish_target(): + registry = FileSystemWorldRegistry() + for world_version_id in ["jade_court_exam@1.0.0", "jade_court_romance@1.0.0"]: + runtime = registry.get_runtime_bundle(world_version_id) + assert _voice_separation_score(runtime.worldpack.voice_profiles) >= 0.70 + + +def test_jade_same_scene_function_dialogue_uses_role_distinct_templates(): + registry = FileSystemWorldRegistry() + for world_version_id in ["jade_court_exam@1.0.0", "jade_court_romance@1.0.0"]: + runtime = registry.get_runtime_bundle(world_version_id) + events = [ + event + for event in runtime.event_atoms + if event.scene_function == "vow_payment" and len(event.actors) >= 2 + ] + yu_cheng_event = next(event for event in events if event.actors[0] == "yu_cheng") + lin_wan_event = next(event for event in events if event.actors[0] == "lin_wan") + state = type(runtime.initial_state).from_dict({**runtime.initial_state.to_dict(), "chapter_index": 260}) + samples = [] + for event in [yu_cheng_event, lin_wan_event]: + beat = type( + "Beat", + (), + { + "event": event, + "dramatic_job": "pressure", + "beat_index": 2, + "beat_label": event.title, + }, + )() + samples.append(compose_dialogue(runtime.world_record.world, state, beat, repeated=True)) + + first_lines = [sample.split("”", 1)[0] for sample in samples] + assert samples[0] != samples[1] + assert first_lines[0] != first_lines[1] + + +def test_targeted_q03_worldpacks_expose_richer_asset_coverage(): + registry = FileSystemWorldRegistry() + expectations = { + "xianxia_forgotten_vow@0.1.0": 5, + "urban_mystery_lotus_lane@0.1.0": 5, + "jade_court_exam@1.0.0": 11, + } + for world_version_id, min_scene_count in expectations.items(): + runtime = registry.get_runtime_bundle(world_version_id) + worldpack = runtime.worldpack + assert len(worldpack.scene_blueprints) >= min_scene_count + for payload in worldpack.voice_profiles.values(): + assert len(payload.get("opening_style", [])) >= 3 + assert len(payload.get("pressure_style", [])) >= 3 + assert len(payload.get("pivot_style", [])) >= 3 + assert len(payload.get("aftermath_style", [])) >= 3 + assert len(payload.get("echo_style", [])) >= 3 + for payload in worldpack.response_cadence_profiles.values(): + for beat_key in ["entry", "pressure", "pivot", "aftermath", "echo"]: + assert len((payload.get("reaction_lines") or {}).get(beat_key, [])) >= 3 + assert len((payload.get("reply_lines") or {}).get(beat_key, [])) >= 3 + scene_openings = ((worldpack.scene_realization_contracts or {}).get("default") or {}).get("scene_openings") or {} + assert scene_openings + for variants in scene_openings.values(): + assert len(variants) >= 3 + + +def test_synthetic_repeated_dialogue_rotates_by_chapter(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("synthetic_min_pack@0.1.0") + beat = type("Beat", (), {"event": runtime.event_atoms[0], "dramatic_job": "pressure", "beat_index": 2})() + samples = [] + for chapter_index in [1, 37, 78]: + state = type(runtime.initial_state).from_dict({**runtime.initial_state.to_dict(), "chapter_index": chapter_index}) + samples.append(compose_dialogue(runtime.world_record.world, state, beat, repeated=True)) + + assert len(set(samples)) >= 2 + assert any("两人都知道,话已经绕不过刚才留下的那层意思了。" not in sample for sample in samples) + + +def _beat_for_scene_function(runtime, scene_function: str, *, dramatic_job: str = "pressure", beat_index: int = 2): + event = next(item for item in runtime.event_atoms if item.scene_function == scene_function and len(item.actors) >= 2) + return type( + "Beat", + (), + { + "event": event, + "dramatic_job": dramatic_job, + "beat_index": beat_index, + "beat_label": event.title, + }, + )() + + +def test_reader_q03_dialogue_rotates_for_jade_and_xianxia_chapter_windows(): + registry = FileSystemWorldRegistry() + targets = [ + ("jade_court_exam@1.0.0", "vow_payment"), + ("jade_court_romance@1.0.0", "misrecognition"), + ("xianxia_forgotten_vow@0.1.0", "karma_ripening"), + ] + for world_version_id, scene_function in targets: + runtime = registry.get_runtime_bundle(world_version_id) + beat = _beat_for_scene_function(runtime, scene_function) + samples = [] + for chapter_index in [21, 220, 260, 460, 480]: + state = type(runtime.initial_state).from_dict({**runtime.initial_state.to_dict(), "chapter_index": chapter_index}) + samples.append(compose_dialogue(runtime.world_record.world, state, beat, repeated=True)) + + assert len(set(samples)) >= 4 + assert len({sample.split(":“", 1)[0] for sample in samples}) >= 3 + assert len({sample for sample in samples if "别只给我半句" in sample}) <= 1 + assert len({sample for sample in samples if "不会再推给局势" in sample}) <= 1 + + +def test_late_longform_compact_exchange_is_dense_rotating_and_clean(): + registry = FileSystemWorldRegistry() + targets = [ + ("jade_court_exam@1.0.0", "vow_payment"), + ("jade_court_romance@1.0.0", "misrecognition"), + ] + forbidden = ["这一章", "这一幕", "scene", "beat", "Q03", "slot", "None", "{}"] + for world_version_id, scene_function in targets: + runtime = registry.get_runtime_bundle(world_version_id) + beat = _beat_for_scene_function(runtime, scene_function) + samples = [] + for chapter_index in [21, 220, 260, 460, 480]: + state = type(runtime.initial_state).from_dict({**runtime.initial_state.to_dict(), "chapter_index": chapter_index}) + sample = compose_late_longform_compact_exchange( + runtime.world_record.world, + state, + beat, + repeated=True, + variant_offset=chapter_index, + ) + lint = lint_chapter_draft(sample) + samples.append(sample) + assert lint["dialogue_count"] >= 4 + assert float(lint["dialogue_plus_action_ratio"]) >= 0.75 + assert all(token not in sample for token in forbidden) + + assert len(set(samples)) >= 4 + + +def test_reader_scene_card_metadata_rotates_for_redundancy_audit(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("jade_court_exam@1.0.0") + beat = _beat_for_scene_function(runtime, "temptation", dramatic_job="pressure", beat_index=2) + body = "余澄低声道:“这一回我先接住杯沿边那层后果。” 林绾回道:“把后半句也放到门影旁。” 余澄又说:“该疼的地方我不躲了。”" + summaries = set() + beat_surfaces = set() + quotes = set() + for chapter_index in [21, 220, 260, 460, 480]: + state_after = type(runtime.initial_state).from_dict({**runtime.initial_state.to_dict(), "chapter_index": chapter_index}) + summaries.add(_chapter_summary(runtime.world_record.world, runtime.initial_state, state_after, beat.event)) + beat_surfaces.add(" ".join(_reader_story_beats(runtime.world_record.world, runtime.initial_state, state_after, [beat]))) + quotes.add(_reader_pull_quote(body, state_after, [beat])) + + assert len(summaries) >= 4 + assert len(beat_surfaces) >= 4 + assert len(quotes) >= 2 + + +def test_reader_chapter_titles_rotate_without_internal_scene_tokens(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("jade_court_exam@1.0.0") + beat = _beat_for_scene_function(runtime, "vow_payment", dramatic_job="pressure", beat_index=2) + chapter_plan = ChapterPlan( + chapter_index=21, + story_phase="crisis", + scene_intent=SceneIntent( + intent_id="sacrifice_test", + label="真正要付出代价的时刻", + description="人物必须承担污名来证明选择不是空话。", + preferred_scene_functions=["vow_payment"], + preferred_tags=["sacrifice", "selfhood"], + ), + beat_target=3, + beat_count=1, + ending_ready=False, + selected_event_ids=[beat.event.event_id], + ) + + title_tails = set() + for chapter_index in [21, 220, 260, 460, 480]: + state_after = type(runtime.initial_state).from_dict({**runtime.initial_state.to_dict(), "chapter_index": chapter_index}) + title = _reader_chapter_title(runtime.world_record.world, runtime.initial_state, state_after, chapter_plan, [beat]) + tail = title.split("·", 1)[-1] + title_tails.add(tail.strip()) + assert title.startswith(f"第 {chapter_index} 章 · ") + assert "vow_payment" not in title + assert "_" not in title + assert "真正要付出代价的时刻" not in title + assert "·" not in tail + assert len(tail.strip()) >= 4 + + assert len(title_tails) >= 4 + + +def test_reader_q03_scene_realizer_rotates_openings_hooks_and_pressures(): + registry = FileSystemWorldRegistry() + targets = [ + ("jade_court_exam@1.0.0", "vow_payment"), + ("jade_court_romance@1.0.0", "vow_payment"), + ("xianxia_forgotten_vow@0.1.0", "mask_crack"), + ] + for world_version_id, scene_function in targets: + runtime = registry.get_runtime_bundle(world_version_id) + beat = _beat_for_scene_function(runtime, scene_function, dramatic_job="entry", beat_index=1) + openings = { + realize_scene_opening( + runtime.world_record.world, + beat, + "让人物把眼前选择推到明处", + "家门、旧誓与真心", + chapter_index=chapter_index, + ) + for chapter_index in [1, 21, 220, 260, 460, 480] + } + hooks = { + realize_hook( + runtime.world_record.world, + "这句余波会追到下一次开口之前", + scene_function, + chapter_index=chapter_index, + ) + for chapter_index in [1, 21, 220, 260, 460, 480] + } + + assert len(openings) >= 4 + assert len(hooks) >= 3 + assert not all("真正先逼近的不是答案" in item for item in openings) + + +def test_targeted_q03_scene_opening_pools_are_function_specific(): + registry = FileSystemWorldRegistry() + expectations = { + "jade_court_exam@1.0.0": ["humiliation", "vow_payment", "debt_exchange", "karma_ripening", "misrecognition"], + "jade_court_romance@1.0.0": ["temptation", "confession_window", "humiliation", "vow_payment", "misrecognition"], + "xianxia_forgotten_vow@0.1.0": ["false_peace", "temptation", "mask_crack", "karma_ripening", "vow_payment"], + } + for world_version_id, functions in expectations.items(): + runtime = registry.get_runtime_bundle(world_version_id) + contract = ((runtime.worldpack.scene_realization_contracts or {}).get("default") or {}) + scene_openings = contract.get("scene_openings") or {} + scene_hooks = contract.get("scene_hooks") or {} + scene_pressures = contract.get("scene_pressures") or {} + + opening_pools = [tuple(scene_openings.get(scene_function) or []) for scene_function in functions] + hook_pools = [tuple(scene_hooks.get(scene_function) or []) for scene_function in functions] + assert all(len(pool) >= 3 for pool in opening_pools) + assert all(len(pool) >= 3 for pool in hook_pools) + assert not any("真正先逼近的不是答案" in line for pool in opening_pools for line in pool) + assert len(set(opening_pools)) == len(opening_pools) + assert len(set(hook_pools)) == len(hook_pools) + assert all(len(scene_pressures.get(scene_function) or []) >= 3 for scene_function in functions) diff --git a/tests/test_emotion_actions.py b/tests/test_emotion_actions.py index dc34dc9..aac545c 100644 --- a/tests/test_emotion_actions.py +++ b/tests/test_emotion_actions.py @@ -8,6 +8,19 @@ def test_emotion_actions_differ_across_packs(): urban = registry.get_runtime_bundle("urban_mystery_lotus_lane@0.1.0") jade_beat = type("Beat", (), {"event": jade.event_atoms[0], "dramatic_job": "entry"})() urban_beat = type("Beat", (), {"event": urban.event_atoms[0], "dramatic_job": "entry"})() - jade_text = compose_emotion_action(jade.world_record.world, jade_beat, repeated=False) - urban_text = compose_emotion_action(urban.world_record.world, urban_beat, repeated=False) + jade_text = compose_emotion_action(jade.world_record.world, jade.initial_state, jade_beat, repeated=False) + urban_text = compose_emotion_action(urban.world_record.world, urban.initial_state, urban_beat, repeated=False) assert jade_text != urban_text + + +def test_synthetic_emotion_actions_rotate_by_chapter_index(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("synthetic_min_pack@0.1.0") + beat = type("Beat", (), {"event": runtime.event_atoms[0], "dramatic_job": "pressure", "beat_index": 2})() + early_state = runtime.initial_state + late_state = type(early_state).from_dict({**early_state.to_dict(), "chapter_index": 43}) + + early = compose_emotion_action(runtime.world_record.world, early_state, beat, repeated=True) + late = compose_emotion_action(runtime.world_record.world, late_state, beat, repeated=True) + + assert early != late diff --git a/tests/test_eval_metrics_correlation.py b/tests/test_eval_metrics_correlation.py index b9072c4..fb9cc78 100644 --- a/tests/test_eval_metrics_correlation.py +++ b/tests/test_eval_metrics_correlation.py @@ -17,6 +17,16 @@ from src.narrativeos.repository import SQLAlchemyRepository +def _reviewer_headers(client: TestClient) -> dict[str, str]: + client.post( + "/v1/auth/register", + json={"actor_id": "ops_eval_reviewer", "actor_role": "reviewer", "password": "secret123", "account_id": "ops_eval_reviewer"}, + ) + login = client.post("/v1/auth/login", json={"actor_id": "ops_eval_reviewer", "password": "secret123"}) + assert login.status_code == 200 + return {"Authorization": f"Bearer {login.json()['token']['access_token']}"} + + def _seed_reader_chapter( repository: SQLAlchemyRepository, *, @@ -141,18 +151,31 @@ def test_repository_eval_metrics_computes_real_continuation_correlation(tmp_path correlations = {item["metric"]: item["correlation"] for item in metrics["quality_signal_correlations"]} assert correlations["overall_score"] > 0.9 assert correlations["pacing"] > 0.9 + assert "semantic_paragraph_similarity_score" in correlations + assert "event_coverage_gap_score" in correlations + assert "beat_coverage_gap_score" in correlations + assert "uncovered_beat_count" in correlations + assert "overcovered_beat_count" in correlations + assert "paragraph_similarity_score" in correlations + assert "n_gram_repetition_score" in correlations + assert "beat_structure_repetition_score" in correlations + assert "q03_q09_calibration" in metrics + assert "q03" in metrics["q03_q09_calibration"] + assert "q09" in metrics["q03_q09_calibration"] def test_eval_metrics_endpoint_exposes_correlation_summary(tmp_path): repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "eval_corr_api.db")) app = create_app(repository=repository) client = TestClient(app) + headers = _reviewer_headers(client) - response = client.get("/v1/ops/eval-metrics") + response = client.get("/v1/ops/eval-metrics", headers=headers) assert response.status_code == 200 payload = response.json() assert "continuation_signal_summary" in payload assert "quality_signal_correlations" in payload + assert "q03_q09_calibration" in payload assert "continuation_world_details" in payload assert "continuation_version_details" in payload assert "continuation_sample_accumulation" in payload @@ -199,9 +222,10 @@ def test_eval_metrics_detail_endpoints_return_world_and_version_drilldown(tmp_pa app = create_app(repository=repository) client = TestClient(app) + headers = _reviewer_headers(client) - world_detail = client.get(f"/v1/ops/eval-metrics/worlds/{world['world_id']}") - version_detail = client.get(f"/v1/ops/eval-metrics/world-versions/{world['latest_version']}") + world_detail = client.get(f"/v1/ops/eval-metrics/worlds/{world['world_id']}", headers=headers) + version_detail = client.get(f"/v1/ops/eval-metrics/world-versions/{world['latest_version']}", headers=headers) assert world_detail.status_code == 200 assert version_detail.status_code == 200 diff --git a/tests/test_eval_scorers.py b/tests/test_eval_scorers.py index 0e628e0..f196522 100644 --- a/tests/test_eval_scorers.py +++ b/tests/test_eval_scorers.py @@ -3,6 +3,7 @@ derive_scoring_issues, hook_quality, monetize_ready, + pacing, readability, scene_density, ) @@ -20,6 +21,7 @@ def test_readability_and_scene_density_have_positive_cases(): def test_choice_distinctness_and_hook_quality(): assert choice_distinctness(["先问真相", "先保住她", "先顺着局势"]) > 0.6 assert hook_quality("话停在这里,可下一次开口时,谁都不可能还是刚才那个人。") > 0.7 + assert hook_quality("这一场停在门口,可余波已经压到下一章。") > 0.7 def test_monetize_ready_respects_paywall_continuity(): @@ -67,6 +69,7 @@ def test_q04_q05_q08_q09_soft_issues_are_derived(): scores=scores, exposition_ratio=0.55, concrete_detail_density=0.001, + text_unit_count=600, ending_ready=False, state_after=state, ) @@ -74,6 +77,118 @@ def test_q04_q05_q08_q09_soft_issues_are_derived(): assert {"Q04", "Q05", "Q08", "Q09"} <= codes +def test_longform_thresholds_do_not_trigger_q03_q09_by_default(): + state = NarrativeState.from_dict( + { + "state_id": "s", + "world_id": "w", + "turn_index": 0, + "story_phase": "midpoint", + "chapter_index": 3, + "min_end_turn": 8, + "fate_pressure": 0.1, + "karmic_weather": {}, + "unresolved_debts": [], + "world_facts": [], + "timeline": [], + "characters": {}, + "relationship_graph": [], + "open_promises": [ + { + "promise_id": "p1", + "description": "还有一件事没说完。", + "opened_at_turn": 1, + "due_by_turn": 8, + "holders": ["lead", "counterpart"], + "fulfillment_modes": ["truth"], + "status": "open", + "stakes": "关系与真相", + "tags": ["truth"], + } + ], + "tension": 0.5, + "themes": {}, + "player_intent": {}, + "recent_scene_functions": [], + "visited_event_ids": [], + "route_fingerprint": [], + "rating_ceiling": "PG13", + "word_budget": 2000, + } + ) + scores = EvaluationScores( + readability=0.9, + scene_density=0.92, + character_fidelity=0.85, + causal_continuity=0.88, + pacing=pacing(False, state, 0.24, text_unit_count=1900), + choice_distinctness=0.72, + hook_quality=0.9, + monetize_ready=0.8, + overall_score=0.84, + ) + issues = derive_scoring_issues( + scores=scores, + exposition_ratio=0.33, + concrete_detail_density=0.01, + text_unit_count=1900, + ending_ready=False, + state_after=state, + ) + codes = {issue.issue_code for issue in issues} + assert "Q09" not in codes + + +def test_longform_q09_still_triggers_when_hook_and_promises_are_weak(): + state = NarrativeState.from_dict( + { + "state_id": "s", + "world_id": "w", + "turn_index": 0, + "story_phase": "midpoint", + "chapter_index": 5, + "min_end_turn": 8, + "fate_pressure": 0.1, + "karmic_weather": {}, + "unresolved_debts": [], + "world_facts": [], + "timeline": [], + "characters": {}, + "relationship_graph": [], + "open_promises": [], + "tension": 0.5, + "themes": {}, + "player_intent": {}, + "recent_scene_functions": [], + "visited_event_ids": [], + "route_fingerprint": [], + "rating_ceiling": "PG13", + "word_budget": 2000, + } + ) + scores = EvaluationScores( + readability=0.88, + scene_density=0.9, + character_fidelity=0.85, + causal_continuity=0.88, + pacing=pacing(False, state, 0.22, text_unit_count=1900), + choice_distinctness=0.72, + hook_quality=0.2, + monetize_ready=0.8, + overall_score=0.84, + ) + issues = derive_scoring_issues( + scores=scores, + exposition_ratio=0.3, + concrete_detail_density=0.01, + text_unit_count=1900, + ending_ready=False, + state_after=state, + ) + codes = {issue.issue_code for issue in issues} + assert "Q09" in codes + + def test_phase_penalty_strongly_discourages_terminal_events_before_min_end_turn(): state = NarrativeState.from_dict( { diff --git a/tests/test_eval_validators.py b/tests/test_eval_validators.py index a8d88fc..366e4e4 100644 --- a/tests/test_eval_validators.py +++ b/tests/test_eval_validators.py @@ -5,6 +5,7 @@ paragraph_repetition_validator, premature_ending_validator, ) +from src.narrativeos.repetition_detector import repetition_signal_bundle from src.narrativeos.models import NarrativeState @@ -14,10 +15,123 @@ def test_engineering_and_meta_validators(): def test_repetition_validator_hits_repeated_paragraphs(): - issues = paragraph_repetition_validator(["同一句话重复很多次", "同一句话重复很多次", "另一句"]) + issues = paragraph_repetition_validator( + ["同一句话重复很多次", "同一句话重复很多次", "另一句"], + text_unit_count_value=60, + ) assert issues +def test_repetition_validator_uses_longform_context_for_1600_unit_chapters(): + bundle = { + "lexical_repetition_score": 0.164, + "paragraph_similarity_score": 0.473, + "n_gram_repetition_score": 0.162, + "beat_structure_repetition_score": 0.1, + "suspicious_refrain_count": 0, + "semantic_paragraph_similarity_score": 0.759, + "event_coverage_gap_score": 0.273, + "beat_coverage_gap_score": 0.182, + "uncovered_event_count": 0, + "uncovered_beat_count": 0, + "overcovered_beat_count": 0, + "selected_event_ids": ["evt_1", "evt_2", "evt_3"], + "coverage_gap_examples": [], + } + + shortform_issues = paragraph_repetition_validator( + ["潮声落下。", "纸页翻动。", "她抬眼。"], + text_unit_count_value=1633, + precomputed_bundle=bundle, + ) + longform_issues = paragraph_repetition_validator( + ["潮声落下。", "纸页翻动。", "她抬眼。"], + text_unit_count_value=1633, + coverage_context={"chapter_task": {"target_words": 1600}}, + precomputed_bundle=bundle, + ) + + assert shortform_issues + assert not longform_issues + + +def test_longform_repetition_validator_uses_structural_signals_not_length_alone(): + lines = [ + "灯影落在窗纸上,风从檐下掠过去,她没有立刻说话,只把那卷录音带重新压回掌心里。", + "灯影落在窗纸上,风从檐下掠过去,她没有立刻说话,只把那卷录音带重新压回掌心里。", + "灯影落在窗纸上,风从檐下掠过去,她没有立刻说话,只把那卷录音带重新压回掌心里。", + "他抬眼看她,知道这一步不是把真相说出来就算完,而是要连代价一起接住。", + ] + issues = paragraph_repetition_validator(lines, text_unit_count_value=1900) + assert issues + + +def test_longform_repetition_validator_uses_semantic_similarity_and_coverage_gap(): + lines = [ + "她把录音带翻到背面,指腹慢慢擦过已经泡开的纸签,像在确认那道旧伤是不是还留在这里。", + "她把录音带翻到背面,指腹慢慢擦过已经泡开的纸签,像在确认那道旧伤是不是还留在这里。", + "她把录音带翻到背面,指腹慢慢擦过已经泡开的纸签,像在确认那道旧伤是不是还留在这里。", + "他没有立即接话,只把潮湿的纸袋压在案边,像是先让真正该出现的那一句话自己浮上来。", + ] + issues = paragraph_repetition_validator( + lines, + text_unit_count_value=1900, + coverage_context={ + "selected_event_ids": ["evt_1", "evt_2", "evt_3"], + "scene_beats": [ + { + "beat_label": "翻出旧物", + "dramatic_job": "entry", + "event": {"event_id": "evt_1", "title": "翻出旧录音带", "summary": "她从纸袋里翻出旧录音带。", "scene_function": "truth_trial", "location": "档案仓", "tags": ["truth", "memory"]}, + }, + { + "beat_label": "追问来源", + "dramatic_job": "pressure", + "event": {"event_id": "evt_2", "title": "追问录音带来源", "summary": "他逼问这卷带子为什么会回到这里。", "scene_function": "temptation", "location": "档案仓", "tags": ["truth", "pressure"]}, + }, + { + "beat_label": "揭出名字", + "dramatic_job": "pivot", + "event": {"event_id": "evt_3", "title": "看见旧名字", "summary": "她在纸签背面看见旧案里那个人的名字。", "scene_function": "confession_window", "location": "档案仓", "tags": ["truth", "memory"]}, + }, + ], + }, + ) + assert issues + + +def test_longform_repetition_bundle_dedupes_repeated_event_ids_for_event_coverage(): + lines = [ + "余澄当众接下春闱之命这一刻,花厅里的风、灯影和脚步声像一起把这步表面平静推到了明处。", + "荣老太君顺着体面把最难认的那句逼到余澄面前,这一拍不再新增事件,而是把真正要转向的那句终于逼到眼前直接推回当前章节正文。", + "门影、茶气和席间静气都没有散,反而把后半句留在了余澄自己身前。", + ] + bundle = repetition_signal_bundle( + lines, + coverage_context={ + "selected_event_ids": ["evt_1", "evt_2", "evt_2"], + "scene_beats": [ + { + "beat_label": "起势:余澄当众接下春闱之命", + "dramatic_job": "entry", + "event": {"event_id": "evt_1", "title": "余澄当众接下春闱之命", "summary": "余澄在花厅当众应下应试。", "scene_function": "false_peace", "location": "花厅", "tags": ["duty"]}, + }, + { + "beat_label": "逼近:荣老太君顺着体面把最难认的那句逼到余澄面前", + "dramatic_job": "pressure", + "event": {"event_id": "evt_2", "title": "荣老太君顺着体面把最难认的那句逼到余澄面前", "summary": "荣老太君借体面逼余澄认下心意。", "scene_function": "truth_trial", "location": "花厅", "tags": ["duty", "truth"]}, + }, + { + "beat_label": "转向:荣老太君顺着体面把最难认的那句逼到余澄面前 · 真正要转向的那句终于逼到眼前", + "dramatic_job": "pivot", + "event": {"event_id": "evt_2", "title": "荣老太君顺着体面把最难认的那句逼到余澄面前", "summary": "这一拍不再新增事件,而是把真正要转向的那句逼回正文。", "scene_function": "truth_trial", "location": "花厅", "tags": ["duty", "truth"]}, + }, + ], + }, + ) + assert bundle["selected_event_ids"] == ["evt_1", "evt_2"] + + def test_structure_and_premature_ending_validators(): issues = chapter_structure_validator( text="太短了。", diff --git a/tests/test_generation_hard_constraints.py b/tests/test_generation_hard_constraints.py new file mode 100644 index 0000000..711bfd4 --- /dev/null +++ b/tests/test_generation_hard_constraints.py @@ -0,0 +1,216 @@ +from src.narrativeos.models import EvaluationDecision, EvaluationReport, EvaluationScores +from src.narrativeos.quality.adapter import build_guardrail_records +from src.narrativeos.quality.hard_constraints import ( + DEFAULT_READER_CHOICE, + build_generation_hard_constraint_prompt_contract, + enforce_generation_hard_constraints, + evaluate_reader_generation_hard_constraints, + resolve_generation_hard_constraint_profile, + summarize_generation_hard_constraints, +) +from src.narrativeos.quality.models import GroundingCheck + + +def _report() -> EvaluationReport: + return EvaluationReport( + chapter_id="chapter_hard_constraints", + world_version_id="test_world@0.1.0", + session_id="session_hard_constraints", + decision=EvaluationDecision(decision="pass", reason="test"), + issues=[], + scores=EvaluationScores( + readability=0.8, + scene_density=0.7, + character_fidelity=0.9, + causal_continuity=0.85, + pacing=0.75, + choice_distinctness=0.65, + hook_quality=0.7, + monetize_ready=0.6, + overall_score=0.76, + ), + hard_validator_results={"failed": False}, + summary="test report", + created_at="2026-04-27T12:00:00+00:00", + ) + + +def _failed_grounding() -> GroundingCheck: + return GroundingCheck( + grounding_check_id="grounding_failed_hard_constraints", + trace_id=None, + status="failed", + confidence=0.1, + evidence_refs=[], + unsupported_claims=["没有支撑的真相。"], + reason_codes=["grounding_missing_support"], + summary="failed", + source_surface="reader", + world_version_id="test_world@0.1.0", + session_id="session_hard_constraints", + chapter_id="chapter_hard_constraints", + ) + + +def test_genre_profile_cannot_disable_universal_rules(): + profile = resolve_generation_hard_constraint_profile( + target_chapters=50, + genre_profile="urban_mystery", + config={ + "generation_hard_constraints": { + "genre_profiles": { + "mystery": { + "aliases": ["urban_mystery"], + "disabled_rules": ["grounding_failed", "schema_complete"], + "threshold_overrides": {"min_choice_count": 3}, + } + } + } + }, + ) + + assert "grounding_failed" in profile["active_rules"] + assert "schema_complete" in profile["active_rules"] + assert profile["thresholds"]["min_choice_count"] == 3 + assert profile["profile_warnings"][0]["code"] == "universal_rules_cannot_be_disabled" + + +def test_reader_hard_constraints_detect_schema_slots_and_choice_budget(): + result = evaluate_reader_generation_hard_constraints( + reader_view={ + "chapter_title": "", + "body": "真话窗口又一次打开。被压回去的 、 并没有松开。", + "choices": [DEFAULT_READER_CHOICE, DEFAULT_READER_CHOICE, ""], + "relationship_hints": [], + }, + target_chapters=50, + ) + + assert result["ok"] is False + assert {"schema_complete", "broken_slot", "choice_text_budget"} <= set(result["failed_checks"]) + assert result["length_profile"] == "long_route_50" + + +def test_reader_hard_constraints_include_scene_card_visible_text(): + result = evaluate_reader_generation_hard_constraints( + reader_view={ + "chapter_title": "第 12 章", + "body": "她把潮湿的纸页按在灯下,指腹停在旧印泥边缘,等对方先开口。", + "choices": ["先追问灯下的纸页。", "暂时守住旧印泥。"], + "relationship_hints": [], + "scene_card": { + "summary": "这一章把旧案线索推到眼前。", + "story_beats": ["从这里起,证据开始转向。"], + }, + }, + target_chapters=500, + ) + + assert result["ok"] is False + assert "meta_narration_leak" in result["failed_checks"] + assert any(item["field"] == "scene_card.summary" for item in result["violations"]) + + +def test_hard_constraint_failure_forces_blocked_guardrail_records(): + bundle = enforce_generation_hard_constraints( + { + "report": _report(), + "quality_gate": {"ok": True, "enforced_decision": "pass", "failed_checks": [], "failed_contract_checks": []}, + "grounding_check": _failed_grounding(), + }, + reader_view={ + "chapter_title": "第 12 章", + "body": "这一章从这里起把 event_id -> route_id 的变化解释清楚。", + "choices": ["继续追问眼前的裂口。", "先守住当前证据。"], + }, + grounding_check=_failed_grounding(), + source_surface="reader", + target_chapters=50, + ) + + gate = bundle["quality_gate"] + assert gate["ok"] is False + assert gate["enforced_decision"] == "block" + assert "grounding_failed" in gate["failed_checks"] + assert gate["hard_constraint_result"]["ok"] is False + + records = build_guardrail_records( + quality_bundle=bundle, + scenario_id="reader_continue", + source_surface="reader", + source_ref={"kind": "chapter", "chapter_id": "chapter_hard_constraints", "rendered_text": "正文"}, + world_version_id="test_world@0.1.0", + session_id="session_hard_constraints", + chapter_id="chapter_hard_constraints", + ) + assert records["decision"].status == "blocked" + assert records["event"].payload["hard_constraint_result"]["failed_checks"] + + +def test_generation_hard_constraint_prompt_contract_is_compact_and_cross_genre(): + contract = build_generation_hard_constraint_prompt_contract( + target_chapters=100, + worldpack_payload={"metadata": {"author_brief": {"genre_preset": "xianxia"}}}, + ) + + assert contract["profile_id"] == "fantasy:long_route_50" + assert contract["repair_policy"] == "repair_once_then_fail_closed" + assert any(item["rule_id"] == "grounding_failed" for item in contract["hard_rules"]) + + +def test_summarize_generation_hard_constraints_counts_repairs_and_hard_fails(): + summary = summarize_generation_hard_constraints( + [ + { + "quality_gate": { + "hard_constraint_result": { + "ok": False, + "failed_checks": ["schema_complete"], + "repair_attempts": 1, + } + } + }, + { + "quality_gate": { + "hard_constraint_result": { + "ok": True, + "failed_checks": [], + "repair_attempts": 1, + "repair_success": True, + } + } + }, + ] + ) + + assert summary["hard_fail_count"] == 1 + assert summary["repair_attempt_count"] == 2 + assert summary["repair_success_count"] == 1 + assert summary["violation_mix"][0]["rule_id"] == "schema_complete" + + +def test_summarize_generation_hard_constraints_reports_scene_card_audit(): + summary = summarize_generation_hard_constraints( + [ + { + "quality_gate": { + "hard_constraint_result": { + "ok": False, + "failed_checks": ["meta_narration_leak"], + "violations": [ + { + "rule_id": "meta_narration_leak", + "issue_code": "Q02", + "field": "scene_card.summary", + } + ], + } + } + } + ] + ) + + audit = summary["scene_card_visible_text_audit"] + assert audit["violation_count"] == 1 + assert audit["failed_rule_mix"][0]["rule_id"] == "meta_narration_leak" + assert summary["field_violation_mix"][0]["field"] == "scene_card.summary" diff --git a/tests/test_generation_pipeline.py b/tests/test_generation_pipeline.py index 786c3f7..4fc065e 100644 --- a/tests/test_generation_pipeline.py +++ b/tests/test_generation_pipeline.py @@ -1,11 +1,19 @@ from pathlib import Path +from copy import deepcopy from src.narrativeos.core.quality_pass import repair_chapter_draft -from src.narrativeos.core.linter import lint_chapter_draft +from src.narrativeos.core.linter import lint_chapter_draft, story_text_unit_count +from src.narrativeos.core.scene_realizer import realize_beat +from src.narrativeos.content_quality_contracts import diagnostic_issue_codes_for_chapter_payload +from src.narrativeos.eval.validators import run_hard_validators +from src.narrativeos.repetition_detector import repetition_signal_bundle from src.narrativeos.core.writer import build_scene_plan, write_chapter_draft -from src.narrativeos.models import ChapterDraft, NarrativeState +from src.narrativeos.models import ChapterDraft, EventAtom, NarrativeState, SceneBeat, SceneRenderSpec from src.narrativeos.pipeline import plan_next_turn_from_events +from src.narrativeos.repository import SQLAlchemyRepository +from src.narrativeos.services.sessions import ReaderContinueCommand, SessionService from src.narrativeos.services.intent_prefill import IntentPrefillService +from src.narrativeos.worldpacks.registry import FileSystemWorldRegistry def test_generation_pipeline_docs_exist(): @@ -42,6 +50,25 @@ def test_writer_and_linter_remove_meta_noise(demo_world, demo_state, demo_events assert "->" not in report["cleaned_text"] +def test_linter_and_hard_validators_surface_disallowed_latin_tokens(demo_state): + text = "她把 temptation 压回喉间,仍旧不肯开口。AI 与 API 只是缩写,不该算成违规正文。" + report = lint_chapter_draft(text) + assert any(item["token"] == "temptation" and item["allowed"] is False for item in report["latin_token_hits"]) + assert any(item["token"] == "AI" and item["allowed"] is True for item in report["latin_token_hits"]) + assert any(item["token"] == "API" and item["allowed"] is True for item in report["latin_token_hits"]) + + hard = run_hard_validators( + text=text, + paragraphs=report["paragraphs"], + dialogue_count=int(report["dialogue_count"]), + action_count=int(report["action_count"]), + detail_count=int(report["detail_count"]), + state_after=demo_state, + ending_ready=False, + ) + assert any(item["token"] == "temptation" for item in hard["disallowed_latin_token_hits"]) + + def test_reader_body_is_clean_and_novelish(demo_world, demo_state, demo_events): result = plan_next_turn_from_events(demo_state, demo_events, world=demo_world) body = result["reader_view"]["body"] @@ -52,6 +79,69 @@ def test_reader_body_is_clean_and_novelish(demo_world, demo_state, demo_events): assert "->" not in body assert "“" in body assert body.count("“") >= 2 + assert 1800 <= story_text_unit_count(body) <= 2200 + + +def test_render_spec_uses_state_word_budget_for_longform_chapters(demo_world, demo_state, demo_events): + demo_state.word_budget = 2000 + result = plan_next_turn_from_events(demo_state, demo_events, world=demo_world, debug=True) + render_spec = result["scene_render_spec"] + assert render_spec["target_word_count"] == 2000 + assert render_spec["min_target_word_count"] == 1800 + assert render_spec["max_target_word_count"] == 2200 + + +def test_scene_realizer_compacts_redundant_continuation_anchor(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("tide_archive_memory_debt@0.1.0") + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.chapter_index = 76 + event = EventAtom.from_dict( + { + "event_id": "evt_compact_anchor", + "title": "false_peace · 临港档案库 · 1", + "summary": "潮汐档案 中,临港档案库 · 1 让人物进一步卷入 false_peace。", + "actors": ["lead", "counterpart"], + "scene_function": "false_peace", + "tags": ["memory_debt", "truth"], + "preconditions_all": [], + "forbidden_if_any": [], + "world_fact_deltas_add": [], + "world_fact_deltas_remove": [], + "belief_updates": {}, + "trust_deltas": [], + "emotion_deltas": [], + "promises_open": [], + "promises_close": [], + "tension_delta": 0.1, + "theme_impacts": {}, + "agency_affordances": [], + "rating_ceiling": "PG13", + "temptation_vector": {}, + "vow_tests": [], + "wound_triggers": [], + "debt_deltas": [], + "karmic_seed_creations": [], + "karmic_seed_resolutions": [], + "awakening_affordances": [], + "concealment_level": 0.0, + "consequence_delay_hint": 1, + "location": "临港档案库", + } + ) + beat = SceneBeat( + beat_index=1, + event=event, + beat_label="起势:临港档案库 · 1", + dramatic_job="entry", + tension_after=0.3, + ) + + text = realize_beat(runtime.world_record.world, state, beat, repeated=False) + + assert "· 1这一拍" not in text + assert "让人物进一步卷入" not in text + assert text.count("临港档案库 · 1") == 0 def test_quality_pass_adds_repair_actions_and_stronger_hook(demo_world, demo_state, demo_events): @@ -85,11 +175,637 @@ def test_quality_pass_adds_repair_actions_and_stronger_hook(demo_world, demo_sta ) assert draft.metadata["quality_pass_applied"] is True assert draft.metadata["quality_pass_actions"] - assert any(token in draft.body for token in ["下一次", "追上来", "还没有散"]) + assert any(token in draft.body for token in ["下一次", "追上来", "还没有散", "绕不过", "真要走到这里", "后半句"]) assert draft.dialogue_count >= 1 assert draft.detail_count >= 2 +def test_quality_pass_can_raise_dialogue_action_balance(demo_world, demo_state, demo_events): + from src.narrativeos.models import SceneBeat, SceneRenderSpec + + debug_result = plan_next_turn_from_events(demo_state, demo_events, world=demo_world, debug=True) + scene_beats = [SceneBeat.from_dict(item) for item in debug_result["scene_beats"]] + render_spec = SceneRenderSpec.from_dict(debug_result["scene_render_spec"]) + scene_plan = build_scene_plan( + world=demo_world, + state_before=demo_state, + chapter_label=debug_result["chapter_plan"]["scene_intent"]["label"], + scene_goal=debug_result["chapter_plan"]["scene_intent"]["description"], + scene_beats=scene_beats, + ending_hook="后面还有更难的一句。", + ) + weak_draft = ChapterDraft( + body="灯影压下来。风从门边过去。纸页轻轻一晃。", + paragraphs=["灯影压下来。", "风从门边过去。", "纸页轻轻一晃。"], + dialogue_count=0, + action_count=0, + detail_count=0, + metadata={}, + ) + draft = repair_chapter_draft( + world=demo_world, + state_before=demo_state, + scene_plan=scene_plan, + scene_beats=scene_beats, + draft=weak_draft, + ) + lint = lint_chapter_draft(draft.body) + assert "q05_dialogue_action_balance" in draft.metadata["quality_pass_actions"] + assert float(lint["dialogue_plus_action_ratio"]) >= 0.42 + + +def test_quality_pass_expands_draft_to_longform_length_gate(demo_world, demo_state, demo_events): + debug_result = plan_next_turn_from_events(demo_state, demo_events, world=demo_world, debug=True) + scene_beats = [SceneBeat.from_dict(item) for item in debug_result["scene_beats"]] + scene_plan = build_scene_plan( + world=demo_world, + state_before=demo_state, + chapter_label=debug_result["chapter_plan"]["scene_intent"]["label"], + scene_goal=debug_result["chapter_plan"]["scene_intent"]["description"], + scene_beats=scene_beats, + ending_hook="后面还有更难的一句。", + ) + weak_draft = ChapterDraft( + body="灯影压下来。风从门边过去。纸页轻轻一晃。", + paragraphs=["灯影压下来。", "风从门边过去。", "纸页轻轻一晃。"], + dialogue_count=0, + action_count=0, + detail_count=0, + metadata={"target_word_count": 2000, "min_target_word_count": 1800, "max_target_word_count": 2200}, + ) + draft = repair_chapter_draft( + world=demo_world, + state_before=demo_state, + scene_plan=scene_plan, + scene_beats=scene_beats, + draft=weak_draft, + ) + assert 1800 <= story_text_unit_count(draft.body) <= 2200 + assert any(action.startswith("length_gate_expand") for action in draft.metadata["quality_pass_actions"]) + + +def test_quality_pass_longform_expansion_avoids_structural_refrains(demo_world, demo_state, demo_events): + scene_beats = [ + SceneBeat(beat_index=1, event=demo_events[0], beat_label=demo_events[0].title, dramatic_job="entry", tension_after=demo_state.tension), + SceneBeat(beat_index=2, event=demo_events[1], beat_label=demo_events[1].title, dramatic_job="pressure", tension_after=demo_state.tension), + SceneBeat(beat_index=3, event=demo_events[2], beat_label=demo_events[2].title, dramatic_job="pivot", tension_after=demo_state.tension), + ] + scene_plan = build_scene_plan( + world=demo_world, + state_before=demo_state, + chapter_label="测试长线扩写", + scene_goal="验证扩写不回到同一句式。", + scene_beats=scene_beats, + ending_hook="后面还有更难的一句。", + ) + weak_draft = ChapterDraft( + body="灯影压下来。风从门边过去。纸页轻轻一晃。", + paragraphs=["灯影压下来。", "风从门边过去。", "纸页轻轻一晃。"], + dialogue_count=0, + action_count=0, + detail_count=0, + metadata={"target_word_count": 2000, "min_target_word_count": 1800, "max_target_word_count": 2200}, + ) + + draft = repair_chapter_draft( + world=demo_world, + state_before=demo_state, + scene_plan=scene_plan, + scene_beats=scene_beats, + draft=weak_draft, + ) + bundle = repetition_signal_bundle(draft.paragraphs) + + assert story_text_unit_count(draft.body) >= 1800 + assert any( + action.startswith("q03_post_length_paragraph_replace") + or action.startswith("q03_bundle_target_replace") + for action in draft.metadata["quality_pass_actions"] + ) + + +def test_quality_pass_final_detail_repair_clears_longform_q05_contract(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("synthetic_min_pack@0.1.0") + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.chapter_index = 46 + state.turn_index = 46 + state.word_budget = 2000 + debug_result = plan_next_turn_from_events(state, runtime.event_atoms, world=runtime.world_record.world, debug=True) + scene_beats = [SceneBeat.from_dict(item) for item in debug_result["scene_beats"]] + render_spec = SceneRenderSpec.from_dict(debug_result["scene_render_spec"]) + scene_plan = build_scene_plan( + world=runtime.world_record.world, + state_before=state, + chapter_label=debug_result["chapter_plan"]["scene_intent"]["label"], + scene_goal=debug_result["chapter_plan"]["scene_intent"]["description"], + scene_beats=scene_beats, + ending_hook="后面还有更难的一句。", + ) + weak_draft = ChapterDraft( + body="\n\n".join(["他把前因后果想得很清楚,却没有真正看见场面。"] * 10), + paragraphs=["他把前因后果想得很清楚,却没有真正看见场面。"] * 10, + dialogue_count=0, + action_count=0, + detail_count=0, + metadata={"target_word_count": 2000, "min_target_word_count": 1800, "max_target_word_count": 2200}, + ) + + draft = repair_chapter_draft( + world=runtime.world_record.world, + state_before=state, + scene_plan=scene_plan, + scene_beats=scene_beats, + draft=weak_draft, + render_spec=render_spec, + ) + lint = lint_chapter_draft(draft.body) + payload = { + "chapter_id": "simulation_synthetic_min_pack@0.1.0_46", + "issues": [], + "hard_validator_results": {"lint_metrics": lint}, + "scores": {"hook_quality": 0.9}, + } + + assert float(lint["concrete_detail_density"]) >= 0.065 + assert "Q05" not in diagnostic_issue_codes_for_chapter_payload(payload, target_chapters=100) + + +def test_quality_pass_final_sweep_clears_longform_q03_q04_contract(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("synthetic_min_pack@0.1.0") + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.chapter_index = 280 + state.turn_index = 280 + state.word_budget = 2000 + debug_result = plan_next_turn_from_events(state, runtime.event_atoms, world=runtime.world_record.world, debug=True) + scene_beats = [SceneBeat.from_dict(item) for item in debug_result["scene_beats"]] + render_spec = SceneRenderSpec.from_dict(debug_result["scene_render_spec"]) + scene_plan = build_scene_plan( + world=runtime.world_record.world, + state_before=state, + chapter_label=debug_result["chapter_plan"]["scene_intent"]["label"], + scene_goal=debug_result["chapter_plan"]["scene_intent"]["description"], + scene_beats=scene_beats, + ending_hook="后面还有更难的一句。", + ) + repeated_exposition = "他把所有因果、关系、压力和后果都想得很清楚,却仍然只是在心里反复说明,没有真正让场面发生变化。" + weak_draft = ChapterDraft( + body="\n\n".join([repeated_exposition] * 12), + paragraphs=[repeated_exposition] * 12, + dialogue_count=0, + action_count=0, + detail_count=0, + metadata={"target_word_count": 2000, "min_target_word_count": 1800, "max_target_word_count": 2200}, + ) + + draft = repair_chapter_draft( + world=runtime.world_record.world, + state_before=state, + scene_plan=scene_plan, + scene_beats=scene_beats, + draft=weak_draft, + render_spec=render_spec, + ) + lint = lint_chapter_draft(draft.body) + payload = { + "chapter_id": "simulation_synthetic_min_pack@0.1.0_280", + "issues": [], + "hard_validator_results": {"lint_metrics": lint}, + "scores": {"hook_quality": 0.9}, + } + + assert story_text_unit_count(draft.body) >= 1800 + assert "Q03" not in diagnostic_issue_codes_for_chapter_payload(payload, target_chapters=500) + assert "Q04" not in diagnostic_issue_codes_for_chapter_payload(payload, target_chapters=500) + + +def test_quality_pass_replaces_reader_q03_refrains_with_chapter_aware_jade_variation(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("jade_court_romance@1.0.0") + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.chapter_index = 460 + state.turn_index = 460 + state.word_budget = 2000 + debug_result = plan_next_turn_from_events(state, runtime.event_atoms, world=runtime.world_record.world, debug=True) + scene_beats = [SceneBeat.from_dict(item) for item in debug_result["scene_beats"]] + render_spec = SceneRenderSpec.from_dict(debug_result["scene_render_spec"]) + scene_plan = build_scene_plan( + world=runtime.world_record.world, + state_before=state, + chapter_label=debug_result["chapter_plan"]["scene_intent"]["label"], + scene_goal=debug_result["chapter_plan"]["scene_intent"]["description"], + scene_beats=scene_beats, + ending_hook="这句余波会追到下一次开口之前。", + ) + repeated = "真正先逼近的不是答案,而是门第、真心和说不出口的后果;这一步只让它再也没法被带过去。" + weak_draft = ChapterDraft( + body="\n\n".join([repeated] * 12), + paragraphs=[repeated] * 12, + dialogue_count=0, + action_count=0, + detail_count=0, + metadata={"target_word_count": 2000, "min_target_word_count": 1800, "max_target_word_count": 2200}, + ) + + draft = repair_chapter_draft( + world=runtime.world_record.world, + state_before=state, + scene_plan=scene_plan, + scene_beats=scene_beats, + draft=weak_draft, + render_spec=render_spec, + ) + lint = lint_chapter_draft(draft.body) + payload = { + "chapter_id": "simulation_jade_court_romance@1.0.0_460", + "issues": [], + "hard_validator_results": {"lint_metrics": lint}, + "scores": {"hook_quality": 0.9}, + } + + assert story_text_unit_count(draft.body) >= 1800 + assert draft.body.count("真正先逼近的不是答案") <= 2 + assert float(lint["concrete_detail_density"]) >= 0.04 + assert "Q03" not in diagnostic_issue_codes_for_chapter_payload(payload, target_chapters=500) + assert "Q04" not in diagnostic_issue_codes_for_chapter_payload(payload, target_chapters=500) + + +def test_longform_jade_generated_drafts_reach_stop_ready_dialogue_ratio(): + registry = FileSystemWorldRegistry() + for world_version_id in ["jade_court_exam@1.0.0", "jade_court_romance@1.0.0"]: + runtime = registry.get_runtime_bundle(world_version_id) + ratios = [] + samples = [] + for chapter_index in [220, 460]: + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.chapter_index = chapter_index + state.turn_index = chapter_index + state.word_budget = 2000 + debug_result = plan_next_turn_from_events(state, runtime.event_atoms, world=runtime.world_record.world, debug=True) + scene_beats = [SceneBeat.from_dict(item) for item in debug_result["scene_beats"]] + render_spec = SceneRenderSpec.from_dict(debug_result["scene_render_spec"]) + scene_plan = build_scene_plan( + world=runtime.world_record.world, + state_before=state, + chapter_label=debug_result["chapter_plan"]["scene_intent"]["label"], + scene_goal=debug_result["chapter_plan"]["scene_intent"]["description"], + scene_beats=scene_beats, + ending_hook=debug_result["chosen_event"]["summary"], + ) + draft = write_chapter_draft( + world=runtime.world_record.world, + state_before=state, + scene_plan=scene_plan, + scene_beats=scene_beats, + render_spec=render_spec, + ) + lint = lint_chapter_draft(draft.body) + ratios.append(float(lint["dialogue_plus_action_ratio"])) + samples.extend(sentence for sentence in draft.body.split("。") if "“" in sentence) + assert story_text_unit_count(draft.body) >= 1840 + assert float(lint["dialogue_plus_action_ratio"]) >= 0.56 + assert float(lint["concrete_detail_density"]) >= 0.04 + assert "这一章" not in draft.body + assert "slot" not in draft.body + + assert min(ratios) >= 0.56 + assert len(set(samples)) >= 4 + + +def test_repetition_signal_bundle_surfaces_structural_refrains(): + paragraphs = [ + "灯影落在窗纸上,风从檐下掠过去,她没有立刻说话,只把那卷录音带重新压回掌心里。", + "灯影落在窗纸上,风从檐下掠过去,她没有立刻说话,只把那卷录音带重新压回掌心里。", + "灯影落在窗纸上,风从檐下掠过去,她没有立刻说话,只把那卷录音带重新压回掌心里。", + "他抬眼看她,知道这一步不是把真相说出来就算完,而是要连代价一起接住。", + ] + bundle = repetition_signal_bundle(paragraphs) + assert bundle["paragraph_similarity_score"] > 0.8 + assert bundle["n_gram_repetition_score"] > 0.1 + assert bundle["suspicious_refrain_count"] >= 1 + + +def test_repetition_signal_bundle_surfaces_semantic_similarity_and_coverage_gap(): + paragraphs = [ + "她把录音带翻到背面,指腹慢慢擦过已经泡开的纸签,像在确认那道旧伤是不是还留在这里。", + "她把录音带翻到背面,指腹慢慢擦过已经泡开的纸签,像在确认那道旧伤是不是还留在这里。", + "他没有立即接话,只把潮湿的纸袋压在案边,像是先让真正该出现的那一句话自己浮上来。", + ] + bundle = repetition_signal_bundle( + paragraphs, + coverage_context={ + "selected_event_ids": ["evt_1", "evt_2"], + "scene_beats": [ + { + "beat_label": "翻出旧物", + "dramatic_job": "entry", + "event": {"event_id": "evt_1", "title": "翻出旧录音带", "summary": "她从纸袋里翻出旧录音带。", "scene_function": "truth_trial", "location": "档案仓", "tags": ["truth", "memory"]}, + }, + { + "beat_label": "追问来源", + "dramatic_job": "pressure", + "event": {"event_id": "evt_2", "title": "追问录音带来源", "summary": "他逼问这卷带子为什么会回到这里。", "scene_function": "temptation", "location": "档案仓", "tags": ["truth", "pressure"]}, + }, + ], + }, + ) + assert bundle["semantic_paragraph_similarity_score"] > 0.7 + assert bundle["event_coverage_gap_score"] >= 0.0 + assert bundle["beat_coverage_gap_score"] >= 0.0 + assert "semantic_paragraph_similarity_pairs" in bundle + + +def test_repetition_coverage_ignores_generic_synthetic_summary_language(): + paragraphs = [ + "中庭里的脚步和回声把入场压实,谁都没法再绕开。甲抬手按住案角,乙没有替他收场。", + "长廊里的窗纸、杯沿和门框一起把试探推到明处,甲低声道:“这一步我认。”", + "表面平静没有真的安静下去,下一次开口前,那句还没说透的话还会追上来。", + ] + bundle = repetition_signal_bundle( + paragraphs, + coverage_context={ + "selected_event_ids": ["synthetic_min_pack__synthetic_setup__0"], + "scene_beats": [ + { + "beat_label": "起势:synthetic_setup · 入场", + "dramatic_job": "entry", + "event": { + "event_id": "synthetic_min_pack__synthetic_setup__0", + "title": "synthetic_setup · 入场", + "summary": "Synthetic Minimal Pack 中,入场 让人物进一步卷入 setup。", + "scene_function": "false_peace", + "location": "中庭", + "tags": ["synthetic", "benchmark"], + }, + } + ], + }, + ) + + assert bundle["event_coverage_gap_score"] < 0.42 + assert bundle["beat_coverage_gap_score"] < 0.35 + assert bundle["uncovered_beat_count"] == 0 + + +def test_repetition_coverage_ignores_generic_projection_turn_anchor(): + paragraphs = [ + "临港档案库里的防潮盒被闻汐按在灯下,空白页边缘的水痕还没干,顾沉舟低声道:“这不是普通手续错误。”", + "何默把旧熟人的价码推到桌边:“我能补齐事故断层,但你要承认真正会伤人的那一段还在。” 闻汐没有退。", + "见旧熟人被临港档案库里的脚步和冷光压实,代价、退路和态度都落到两人面前。", + ] + bundle = repetition_signal_bundle( + paragraphs, + coverage_context={ + "selected_event_ids": [ + "tide_archive_memory_debt__archive_anomaly__0", + "tide_archive_memory_debt__black_market_offer__0", + "tide_archive_memory_debt__black_market_offer__0__beat_projection__3_pivot", + ], + "scene_beats": [ + { + "beat_label": "起势:archive_anomaly · 闻汐从防潮盒里抽出被替换过的空白页", + "dramatic_job": "entry", + "event": { + "event_id": "tide_archive_memory_debt__archive_anomaly__0", + "title": "archive_anomaly · 闻汐从防潮盒里抽出被替换过的空白页", + "summary": "潮汐档案 中,闻汐从防潮盒里抽出被替换过的空白页 让人物进一步卷入 false_peace。", + "scene_function": "false_peace", + "location": "临港档案库", + "tags": ["闻汐和顾沉舟在档案库第一次正面对上那段缺失的原始记忆,谁都意识到这不是普通手续错误。"], + }, + }, + { + "beat_label": "逼近:black_market_offer · 见旧熟人", + "dramatic_job": "pressure", + "event": { + "event_id": "tide_archive_memory_debt__black_market_offer__0", + "title": "black_market_offer · 见旧熟人", + "summary": "潮汐档案 中,见旧熟人 让人物进一步卷入 temptation。", + "scene_function": "temptation", + "location": "临港档案库", + "tags": ["何默提出能替闻汐暂时补齐事故断层,但代价是把真正会伤人的一段永远挪走。"], + }, + }, + { + "beat_label": "转向:black_market_offer · 见旧熟人 · 真正要转向的那句终于逼到眼前", + "dramatic_job": "pivot", + "event": { + "event_id": "tide_archive_memory_debt__black_market_offer__0__beat_projection__3_pivot", + "title": "black_market_offer · 见旧熟人 · 真正要转向的那句终于逼到眼前", + "summary": "真正要转向的那句终于逼到眼前继续压在临港档案库里,刚才没说透的态度、代价和退路都被逼到明处。", + "scene_function": "temptation", + "location": "临港档案库", + "tags": ["何默提出能替闻汐暂时补齐事故断层,但代价是把真正会伤人的一段永远挪走。"], + }, + }, + ], + }, + ) + + assert bundle["event_coverage_gap_score"] < 0.5 + assert bundle["uncovered_beat_count"] == 0 + + +def test_repetition_coverage_uses_salient_terms_for_long_chinese_continuation_anchor(): + paragraphs = [ + "花厅里的脚步和回声把荣老太君那句追问压实,余澄没有退,只把手按在纸页边缘。", + "徐师没有替他把话说圆,书房外落下一串细响,余澄低声道:“我听见了,也会照着做。”", + "林绾在回廊里看住他:“别再拿更轻的话压回去。” 余澄把窗纸边的冷光接住,没有再绕开。", + ] + bundle = repetition_signal_bundle( + paragraphs, + coverage_context={ + "selected_event_ids": [ + "accept_exam_nomination__continuation__404_1_confession_window", + ], + "scene_beats": [ + { + "beat_label": "转向:徐师在书房里把那句不能再装糊涂的话留给余澄自己来认", + "dramatic_job": "pivot", + "event": { + "event_id": "accept_exam_nomination__continuation__404_1_confession_window", + "title": "徐师在书房里把那句不能再装糊涂的话留给余澄自己来认", + "summary": "花厅散后,余澄走进书房,徐师没有安慰,只把那句最难听的话放在他面前。", + "scene_function": "confession_window", + "location": "书房", + "tags": ["truth", "selfhood"], + }, + }, + ], + }, + ) + + assert bundle["event_coverage_gap_score"] < 0.42 + assert bundle["beat_coverage_gap_score"] < 0.35 + assert bundle["uncovered_event_count"] == 0 + assert bundle["uncovered_beat_count"] == 0 + + +def test_jade_court_exam_first_reader_chapter_persists_after_quality_pass(): + repository = SQLAlchemyRepository(database_url="sqlite://") + service = SessionService(repository) + session = service.create_session("jade_court_exam", reader_id="reader_quality_probe") + + result = service.continue_story( + ReaderContinueCommand( + session_id=session["session_id"], + freeform_intent="我先顺着家里来,但我也想给自己留后路。", + ), + reader_id="reader_quality_probe", + ) + + assert result["status"] == "ok" + assert result.get("code") is None + assert result["reader_view"]["chapter_title"] + assert story_text_unit_count(result["reader_view"]["body"]) >= 900 + + +def test_writer_varies_consecutive_same_scene_function_beats(demo_world, demo_state, demo_events): + first = demo_events[0] + second_payload = deepcopy(first.to_dict()) + second_payload["event_id"] = f"{first.event_id}__variant" + second = EventAtom.from_dict(second_payload) + scene_beats = [ + SceneBeat(beat_index=1, event=first, beat_label=first.title, dramatic_job="entry", tension_after=demo_state.tension), + SceneBeat(beat_index=2, event=second, beat_label=second.title, dramatic_job="pressure", tension_after=demo_state.tension), + ] + scene_plan = build_scene_plan( + world=demo_world, + state_before=demo_state, + chapter_label="测试重复场景变体", + scene_goal="验证同类 beat 也能保持变体。", + scene_beats=scene_beats, + ending_hook="还有话没说完", + ) + draft = write_chapter_draft( + world=demo_world, + state_before=demo_state, + scene_plan=scene_plan, + scene_beats=scene_beats, + render_spec=SceneRenderSpec( + prose_mode="novel_lush", + viewpoint_character="", + target_word_count=900, + dialogue_density=0.35, + sensory_motifs=[], + emotional_pivot="test", + ending_cadence="lingering", + must_include_beats=[], + ), + ) + assert len(draft.paragraphs) >= 3 + assert draft.paragraphs[1] != draft.paragraphs[2] + + +def test_writer_anchors_event_titles_into_body(demo_world, demo_state, demo_events): + first = demo_events[0] + second_payload = deepcopy(first.to_dict()) + second_payload["event_id"] = f"{first.event_id}__anchor" + second_payload["title"] = "录音带再次翻出来" + second = EventAtom.from_dict(second_payload) + scene_beats = [ + SceneBeat(beat_index=1, event=first, beat_label=first.title, dramatic_job="entry", tension_after=demo_state.tension), + SceneBeat(beat_index=2, event=second, beat_label=second.title, dramatic_job="pressure", tension_after=demo_state.tension), + ] + scene_plan = build_scene_plan( + world=demo_world, + state_before=demo_state, + chapter_label="测试事件锚点", + scene_goal="验证正文会把选中的事件落回段落里。", + scene_beats=scene_beats, + ending_hook="后面还有更难的一句", + ) + draft = write_chapter_draft( + world=demo_world, + state_before=demo_state, + scene_plan=scene_plan, + scene_beats=scene_beats, + render_spec=SceneRenderSpec( + prose_mode="novel_lush", + viewpoint_character="", + target_word_count=900, + dialogue_density=0.35, + sensory_motifs=[], + emotional_pivot="test", + ending_cadence="lingering", + must_include_beats=[], + ), + ) + assert "录音带再次翻出来" in draft.body + + +def test_longform_followup_beats_use_projection_events_for_unique_coverage(demo_world, demo_state, demo_events): + result = plan_next_turn_from_events(demo_state, demo_events, world=demo_world, debug=True) + scene_beats = result["scene_beats"] + assert len(scene_beats) >= 3 + assert scene_beats[-1]["event"]["metadata"].get("beat_projection") is True + selected_event_ids = result["chapter_plan"]["selected_event_ids"] + assert len(selected_event_ids) == len(set(selected_event_ids)) + + +def test_aftermath_longform_keeps_two_real_events_when_promises_are_overdue(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("urban_mystery_lotus_lane@0.1.0") + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.story_phase = "aftermath" + state.chapter_index = 20 + state.turn_index = 20 + state.min_end_turn = 12 + state.visited_event_ids = [event.event_id for event in runtime.event_atoms] + state.current_chapter_task = { + "duty_type": "pace_breath", + "promise_actions": ["maintain_continuity"], + } + state.open_promises = [ + item + for item in NarrativeState.from_dict( + { + **state.to_dict(), + "open_promises": [ + { + "promise_id": "alley_meet__promise", + "description": "旧巷那一夜迟早要被真正说清。", + "opened_at_turn": 1, + "due_by_turn": 3, + "holders": ["jiang_yi", "zhou_lan"], + "fulfillment_modes": ["truth"], + "status": "open", + "stakes": "trust", + "tags": ["false_peace"], + }, + { + "promise_id": "truth_request__promise", + "description": "追问不能一直停在半句真话上。", + "opened_at_turn": 2, + "due_by_turn": 4, + "holders": ["jiang_yi", "zhou_lan"], + "fulfillment_modes": ["truth"], + "status": "open", + "stakes": "trust", + "tags": ["truth_trial"], + }, + { + "promise_id": "rooftop_confession__promise", + "description": "天台那次真话不能一直停在风口。", + "opened_at_turn": 5, + "due_by_turn": 7, + "holders": ["jiang_yi", "zhou_lan"], + "fulfillment_modes": ["truth"], + "status": "open", + "stakes": "trust", + "tags": ["confession_window"], + }, + ], + } + ).open_promises + ] + result = plan_next_turn_from_events(state, runtime.event_atoms, world=runtime.world_record.world, debug=True) + per_beat = result["planner_trace_summary"]["per_beat"] + assert len(per_beat) >= 2 + assert per_beat[1]["selected_event_id"] != per_beat[0]["selected_event_id"] + + def test_intent_prefill_service_returns_contract(demo_world, demo_state, demo_events): from src.narrativeos.models import SessionRecord @@ -129,3 +845,15 @@ def test_intent_prefill_service_returns_contract(demo_world, demo_state, demo_ev assert payload["last_player_intent"] assert payload["current_pressure"] assert payload["suggested_prefill"] + + +def test_plan_next_turn_emits_planner_trace_summary(demo_world, demo_state, demo_events): + result = plan_next_turn_from_events(demo_state, demo_events, world=demo_world, debug=False) + planner_trace = result["planner_trace_summary"] + assert "adapted_beat_target" in planner_trace + assert "adapted_min_candidates" in planner_trace + assert "adapted_max_candidates" in planner_trace + assert "per_beat" in planner_trace + assert "max_total_evaluate_latency_ms" in planner_trace + if planner_trace["per_beat"]: + assert "evaluate_candidates_timing_ms" in planner_trace["per_beat"][0] diff --git a/tests/test_grounding_engine.py b/tests/test_grounding_engine.py new file mode 100644 index 0000000..6f5344e --- /dev/null +++ b/tests/test_grounding_engine.py @@ -0,0 +1,72 @@ +from pathlib import Path + +from src.narrativeos.quality.grounding import build_grounding_check, build_grounding_decision +from src.narrativeos.repository import SQLAlchemyRepository + + +def test_grounding_engine_supports_pass_weak_failed_and_not_applicable(): + passed = build_grounding_decision( + scenario_id="reader_continue", + text="她终于决定继续把这句真话说出来。", + coverage_context={ + "selected_event_ids": ["她", "决定", "继续", "真话", "说出来"], + "scene_beats": [{"event": {"title": "她决定说出真话", "summary": "她终于决定继续把真话说出来", "scene_function": "truth_trial", "location": "回廊"}}], + "chapter_task": {"objective": "继续说出真话", "summary": "她决定继续把真话说出来", "target_words": 2200}, + }, + state_after=type("State", (), {"world_facts": ["她决定继续把真话说出来", "回廊里的真话已经被说出"], "open_promises": []})(), + worldpack_payload={"world_bible": {"theme": "真话", "summary": "她决定继续把真话说出来", "location": "回廊"}}, + ) + assert passed.status in {"passed", "weak"} + + weak = build_grounding_decision( + scenario_id="reader_continue", + text="她终于决定继续把这句真话说出来,但是另一段过去仍然压在心里。", + coverage_context={ + "scene_beats": [{"event": {"title": "她决定说出真话", "summary": "她终于决定继续", "scene_function": "truth_trial"}}], + "chapter_task": {"objective": "继续说出真话"}, + }, + state_after=type("State", (), {"world_facts": ["她决定继续"], "open_promises": []})(), + worldpack_payload={"world_bible": {"theme": "真话"}}, + ) + assert weak.status in {"weak", "failed"} + + failed = build_grounding_decision( + scenario_id="publish_candidate", + text="她已经回到了从未存在过的第七王朝,并公开承认旧世界的债已经全部结束。", + coverage_context={ + "scene_beats": [{"event": {"title": "公开承认旧债", "summary": "她承认旧债", "scene_function": "truth_trial"}}], + "chapter_task": {"objective": "承认旧债"}, + }, + state_after=type("State", (), {"world_facts": ["旧债仍未结束"], "open_promises": []})(), + worldpack_payload={"world_bible": {"theme": "旧债"}}, + ) + assert failed.status in {"weak", "failed"} + + not_applicable = build_grounding_decision( + scenario_id="reader_continue", + text="风停了。", + coverage_context={}, + state_after=type("State", (), {"world_facts": [], "open_promises": []})(), + worldpack_payload={}, + ) + assert not_applicable.status == "not_applicable" + + +def test_grounding_check_persists_in_repository(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "grounding_repo.db")) + check = repository.save_grounding_check( + build_grounding_check( + scenario_id="reader_continue", + text="她终于决定继续把这句真话说出来。", + source_surface="reader", + trace_id="trace_grounding_1", + world_version_id="jade_court_exam@0.1.0", + session_id="session_grounding_1", + chapter_id="chapter_grounding_1", + coverage_context={"chapter_task": {"objective": "说出真话"}}, + state_after=type("State", (), {"world_facts": ["说出真话"], "open_promises": []})(), + worldpack_payload={"world_bible": {"theme": "真话"}}, + ).to_dict() + ) + assert check["trace_id"] == "trace_grounding_1" + assert repository.list_grounding_checks(trace_id="trace_grounding_1")[0]["grounding_check_id"] == check["grounding_check_id"] diff --git a/tests/test_learned_assisted_gate.py b/tests/test_learned_assisted_gate.py index ed1b5f0..9be8536 100644 --- a/tests/test_learned_assisted_gate.py +++ b/tests/test_learned_assisted_gate.py @@ -40,7 +40,8 @@ def _pass_simulation(version): "rewrite_rate": 0.0, "block_rate": 0.0, } - simulation["cross_pack_summary"] = simulation.get("cross_pack_summary") or { + simulation["cross_pack_summary"] = { + **dict(simulation.get("cross_pack_summary") or {}), "cross_pack_pass_rate": 1.0, "top_failing_packs": [], "delta_summary": {"cross_pack_pass_rate_delta": 0.0, "regressions": [], "world_deltas": {}}, diff --git a/tests/test_longform_product_readiness.py b/tests/test_longform_product_readiness.py new file mode 100644 index 0000000..2948d4a --- /dev/null +++ b/tests/test_longform_product_readiness.py @@ -0,0 +1,71 @@ +from src.narrativeos.services.longform_capability import build_longform_500_product_readiness + + +def test_longform_500_product_readiness_separates_structure_claim_from_product_ready_evidence(): + readiness = build_longform_500_product_readiness( + claim_safe_band="500", + longform_readiness={"status": "ready"}, + simulation_report={}, + ) + + assert readiness["ready"] is False + assert readiness["product_ready_band"] is None + blocker_keys = {item["key"] for item in readiness["blockers"]} + assert "longform_500_static_signoff_missing" in blocker_keys + assert "longform_500_human_review_closeout_missing" in blocker_keys + assert "longform_500_weakest_stop_ready_missing" in blocker_keys + assert "longform_500_hard_constraint_evidence_missing" in blocker_keys + assert "longform_500_scene_card_visible_text_evidence_missing" in blocker_keys + assert "reader_500_replay_projection_evidence_missing" in blocker_keys + + +def test_longform_500_product_readiness_passes_with_full_paid_pilot_evidence(): + readiness = build_longform_500_product_readiness( + claim_safe_band="1000", + longform_readiness={"status": "ready"}, + simulation_report={ + "cross_pack_summary": { + "longform_500_signoff": {"ready": True}, + "longform_500_interactive_signoff": {"ready": True}, + "longform_500_human_review_closeout": {"ready": True}, + "weakest_pack_polish_program": {"status": "stop_ready", "continue_worlds": []}, + "generation_hard_constraint_summary": { + "chapter_count": 3000, + "hard_fail_count": 0, + "scene_card_visible_text_audit": {"violation_count": 0}, + }, + "reader_replay_projection_summary": {"ready": True}, + "benchmark_runtime_profile": {"total_wall_ms": 3_600_000}, + } + }, + ) + + assert readiness["ready"] is True + assert readiness["product_ready_band"] == "500" + assert readiness["blockers"] == [] + + +def test_longform_500_product_readiness_blocks_when_human_or_weakest_stop_ready_missing(): + readiness = build_longform_500_product_readiness( + claim_safe_band="500", + longform_readiness={"status": "ready"}, + simulation_report={ + "cross_pack_summary": { + "longform_500_signoff": {"ready": True}, + "longform_500_interactive_signoff": {"ready": True}, + "generation_hard_constraint_summary": { + "chapter_count": 3000, + "hard_fail_count": 0, + "scene_card_visible_text_audit": {"violation_count": 0}, + }, + "reader_storybook_500_verification": {"ready": True}, + "benchmark_runtime_profile": {"total_wall_ms": 3_600_000}, + } + }, + ) + + blocker_keys = {item["key"] for item in readiness["blockers"]} + assert readiness["ready"] is False + assert "longform_500_human_review_closeout_missing" in blocker_keys + assert "longform_500_weakest_stop_ready_missing" in blocker_keys + assert readiness["product_ready_band"] is None diff --git a/tests/test_longform_program_l1.py b/tests/test_longform_program_l1.py new file mode 100644 index 0000000..c5ca3e1 --- /dev/null +++ b/tests/test_longform_program_l1.py @@ -0,0 +1,2318 @@ +from __future__ import annotations + +from pathlib import Path + +from src.narrativeos.benchmark.reporting import ( + build_longform_250_interactive_signoff, + build_longform_250_human_review_closeout, + build_longform_250_signoff, + build_longform_1000_feasibility, + build_longform_1000_human_review_closeout, + build_longform_1000_interactive_signoff, + build_longform_1000_readiness, + build_longform_500_ending_signoff, + build_longform_500_human_review_closeout, + build_longform_500_interactive_signoff, + build_longform_500_signoff, +) +from src.narrativeos.benchmark.runner import run_benchmark +from src.narrativeos.longform import configure_longform_runtime, default_chapter_task, longform_min_end_turn_floor, longform_terminal_allowed +from src.narrativeos.pipeline import plan_next_turn_from_events +from src.narrativeos.repository import SQLAlchemyRepository +from src.narrativeos.services.authoring import AuthoringService, _resolve_longform_structure +from src.narrativeos.services.training_signal import TrainingSignalService +from src.narrativeos.worldpacks.registry import FileSystemWorldRegistry +from src.narrativeos.worldpacks.validator import validate_worldpack_payload + + +def test_authoring_brief_generates_longform_planning_skeleton(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_brief.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "longform_l1_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证长篇规划骨架。", + "life_theme": "长线关系与承诺如何持续推进", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + detail = authoring.get_draft(draft["world_version_id"]) + worldpack = detail["worldpack"] + + validation = validate_worldpack_payload(worldpack) + assert validation["ok"] is True + assert worldpack["series_plan"]["total_chapter_target"] == 100 + assert worldpack["series_plan"]["total_volume_target"] == 5 + assert len(worldpack["volume_plans"]) == 5 + assert len(worldpack["arc_plans"]) >= 15 + assert worldpack["chapter_budget_policy"]["default_target_words"] == 2000 + assert worldpack["chapter_budget_policy"]["min_target_words"] == 1800 + assert worldpack["chapter_budget_policy"]["max_target_words"] == 2200 + assert worldpack["metadata"]["longform_program_stage"] == "L1_foundation" + assert worldpack["arc_plans"][0]["chapter_tasks"] + + +def test_configure_longform_runtime_raises_min_end_turn_floor(demo_world, demo_state): + configure_longform_runtime( + demo_state, + series_plan={ + "series_id": "series_demo", + "title": "demo", + "total_volume_target": 5, + "total_chapter_target": 100, + "target_word_count": 200000, + }, + volume_plans=[ + { + "volume_id": "series_demo::volume_1", + "order": 1, + "title": "第1卷", + "goal": "推进", + "target_chapters": 20, + "climax_definition": "改变关系", + "end_state": "打开下一卷", + } + ], + arc_plans=[], + chapter_budget_policy={"default_target_words": 2000}, + world=demo_world, + ) + assert demo_state.min_end_turn == longform_min_end_turn_floor(100) + assert demo_state.metadata["longform_min_end_turn_floor"] == longform_min_end_turn_floor(100) + + +def test_plan_next_turn_exposes_longform_task_and_context(demo_world, demo_state, demo_events): + demo_state.current_series_id = "series_demo" + demo_state.current_volume_id = "series_demo::volume_1" + demo_state.current_arc_id = "series_demo::volume_1::arc_1" + demo_state.word_budget = 2000 + + result = plan_next_turn_from_events( + demo_state, + demo_events, + world=demo_world, + debug=True, + ) + + assert result["status"] == "ok" + assert result["chapter_plan"]["chapter_task"]["duty_type"] + assert result["chapter_plan"]["chapter_task_execution_summary"]["target_words"] == 2000 + assert result["longform_context_pack"]["current_series_id"] == "series_demo" + assert "promise_ledger" in result["longform_context_pack"] + assert "rolling_recap" in result["updated_state"] + + +def test_longform_progression_state_machine_advances_arc_and_volume(tmp_path: Path, demo_world, demo_state): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_progress.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "longform_progress_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证长篇推进状态机。", + "life_theme": "让卷和弧线真正前进", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_progress", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + worldpack = authoring.get_draft(draft["world_version_id"])["worldpack"] + configure_longform_runtime( + demo_state, + series_plan=dict(worldpack["series_plan"]), + volume_plans=list(worldpack["volume_plans"]), + arc_plans=list(worldpack["arc_plans"]), + chapter_budget_policy=dict(worldpack["chapter_budget_policy"]), + world=demo_world, + ) + + demo_state.chapter_index = 1 + first_task = default_chapter_task(demo_state, demo_world) + assert demo_state.metadata["longform_progression"]["volume_order"] == 1 + assert demo_state.metadata["longform_progression"]["arc_order"] == 1 + assert first_task["chapter_task_id"].endswith("task_1") + + demo_state.chapter_index = 7 + second_arc_task = default_chapter_task(demo_state, demo_world) + assert demo_state.metadata["longform_progression"]["volume_order"] == 1 + assert demo_state.metadata["longform_progression"]["arc_order"] == 2 + assert second_arc_task["chapter_task_id"].startswith(f"{demo_state.current_arc_id}::") + + demo_state.chapter_index = 21 + second_volume_task = default_chapter_task(demo_state, demo_world) + assert demo_state.metadata["longform_progression"]["volume_order"] == 2 + assert demo_state.metadata["longform_progression"]["volume_id"].endswith("volume_2") + assert second_volume_task["target_words"] == 2000 + + +def test_longform_terminal_gate_blocks_early_terminal_scene(demo_world, demo_state, demo_events): + terminal_event = demo_events[0] + demo_state.current_series_id = "series_demo" + demo_state.current_volume_id = "series_demo::volume_1" + demo_state.current_arc_id = "series_demo::volume_1::arc_1" + chapter_task = { + "chapter_task_id": "series_demo::task_1", + "objective": "先推进,不允许提前完结", + "duty_type": "advance_plot", + "target_words": 2000, + "reveal_budget": 1, + "promise_actions": ["maintain_continuity"], + "allow_terminal": False, + } + assert longform_terminal_allowed(demo_state, chapter_task, terminal_event) is False + + +def test_simulation_report_includes_longform_drilldown(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_sim.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "longform_sim_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 simulation longform drilldown。", + "life_theme": "章节职责如何持续推进", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + + report = authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=6, + ) + + assert report["longform_drilldown"]["volume_progress"] + assert report["longform_drilldown"]["arc_progress"] + assert report["longform_gate"]["status"] == "not_applicable" + assert report["longform_plan_snapshot"]["series_plan"]["total_chapter_target"] == 100 + + detail = authoring.get_draft(draft["world_version_id"]) + assert detail["promise_ledger_workbench"]["available"] is True + assert "open_count" in detail["promise_ledger_workbench"] + assert detail["promise_state_workbench"]["available"] is True + assert detail["promise_state_workbench"]["editable_promises"] + assert detail["series_volume_arc_promise_mapping"]["available"] is True + assert detail["series_volume_arc_promise_mapping"]["volumes"] + assert detail["chapter_task_simulation_linking"]["available"] is True + assert detail["chapter_task_simulation_linking"]["task_links"] + assert detail["chapter_task_simulation_linking"]["task_links"][0]["linked_chapters"] + assert "promise_targets" in detail["chapter_task_simulation_linking"]["task_links"][0] + assert "planned_promises" in detail["chapter_task_simulation_linking"]["task_links"][0] + assert detail["continuity_diff_workbench"]["available"] is True + assert detail["continuity_override_workbench"]["available"] is True + assert detail["continuity_override_workbench"]["candidate_chapters"] + assert "simulation_freshness" in detail["continuity_diff_workbench"] + + +def test_longform_simulation_survives_past_initial_route_exhaustion(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_survival.db")) + authoring = AuthoringService(repository) + + report = authoring.run_simulation_for_world_version( + authoring._select_candidate_world_version_id("synthetic_min_pack"), + include_cross_pack=False, + max_chapters=12, + ) + + assert report["completed_chapters"] >= 12 + assert report["stop_reason"] == "chapter_budget_reached" + + +def test_runtime_fallback_longform_plan_reduces_duty_looping(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_runtime_fallback.db")) + authoring = AuthoringService(repository) + + report = authoring.run_simulation_for_world_version( + authoring._select_candidate_world_version_id("synthetic_min_pack"), + include_cross_pack=False, + max_chapters=12, + ) + + duties = [str((item.get("chapter_task") or {}).get("duty_type") or "") for item in report["chapter_trace"]] + repeats = sum(1 for index in range(1, len(duties)) if duties[index] == duties[index - 1]) + assert report["longform_plan_snapshot"]["plan_source"] == "runtime_fallback" + assert report["longform_summary"]["arc_task_repeat_rate"] <= 0.25 + assert repeats == 0 + + +def test_authoring_can_bootstrap_longform_workbench_for_legacy_pack(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_bootstrap.db")) + registry = FileSystemWorldRegistry() + authoring = AuthoringService(repository, registry=registry) + pack = registry.get_published_world("urban_mystery_lotus_lane")["worldpack"] + draft = authoring.save_draft(pack, change_context={"source": "legacy_clone", "label": "复制旧 pack"}) + + bootstrapped = authoring.bootstrap_longform_workbench(draft["world_version_id"]) + worldpack = bootstrapped["worldpack"] + + assert worldpack["series_plan"]["total_chapter_target"] >= 24 + assert worldpack["volume_plans"] + assert worldpack["arc_plans"] + assert worldpack["metadata"]["longform_program_stage"] == "L2_workbench" + assert worldpack["metadata"]["longform_workbench_bootstrapped"] is True + + +def test_authoring_can_persist_chapter_task_edit(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_task_edit.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "longform_task_edit_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 chapter task 编辑持久化。", + "life_theme": "让任务编辑回写到 draft", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + detail = authoring.get_draft(draft["world_version_id"]) + worldpack = detail["worldpack"] + first_arc = worldpack["arc_plans"][0] + first_task = first_arc["chapter_tasks"][0] + first_task["duty_type"] = "deliver_climax" + first_task["objective"] = "把这一章改成更强的高潮推进。" + first_task["target_words"] = 2300 + first_task["reveal_budget"] = 3 + first_task["promise_actions"] = ["advance_payoff", "close_arc_loop"] + first_task["promise_targets"] = ["promise_1", "promise_2"] + first_task["allow_terminal"] = True + + updated = authoring.update_draft( + draft["world_version_id"], + worldpack, + change_context={"source": "longform_editor", "label": "保存 chapter task"}, + ) + + persisted_task = updated["worldpack"]["arc_plans"][0]["chapter_tasks"][0] + assert persisted_task["duty_type"] == "deliver_climax" + assert persisted_task["objective"] == "把这一章改成更强的高潮推进。" + assert persisted_task["target_words"] == 2300 + assert persisted_task["reveal_budget"] == 3 + assert persisted_task["promise_actions"] == ["advance_payoff", "close_arc_loop"] + assert persisted_task["promise_targets"] == ["promise_1", "promise_2"] + assert persisted_task["allow_terminal"] is True + + +def test_quick_brief_simulation_surfaces_promise_runway_summary(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "quick_brief_runway_summary.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "xianxia", + "world_title": "quick_brief_runway_world", + "lead_name": "沈照", + "counterpart_name": "叶青烛", + "core_premise": "验证 quick brief 也会给出 promise runway summary。", + "life_theme": "长线续航需要 promises 先撑住", + "locations": "太玄山门\n沉星古渡\n北辰废宫", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 10, + "target_word_count": 220000, + } + ) + + authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=12, + ) + detail = authoring.get_draft(draft["world_version_id"]) + runway = detail["promise_runway_summary"] + assert runway["available"] is True + assert runway["open_count"] >= 1 + assert runway["runway_status"] in {"healthy", "thinning", "exhausted"} + assert "promise_runway_summary" in detail["longform_drilldown"] + + +def test_longform_drilldown_flags_midrun_structure_exhaustion(): + repository = SQLAlchemyRepository(database_url="sqlite://") + authoring = AuthoringService(repository) + simulation_report = { + "completed_chapters": 36, + "evaluation_summary": { + "top_issue_categories": [ + {"issue_code": "Q04", "count": 4}, + {"issue_code": "Q09", "count": 5}, + ] + }, + "chapter_evaluations": [ + { + "chapter_id": f"chapter_{index}", + "scores": { + "pacing": 0.31, + "hook_quality": 0.44, + "scene_density": 0.52, + "overall_score": 0.41, + }, + "issues": [ + {"issue_code": "Q04"}, + {"issue_code": "Q09"}, + ], + "hard_validator_results": { + "lint_metrics": { + "exposition_ratio": 0.56, + } + }, + } + for index in range(32, 37) + ], + "chapter_trace": [ + { + "chapter_id": f"chapter_{index}", + "chapter_title": f"第{index}章", + "scene_function": "false_peace", + "chapter_task_execution_summary": {"series_chapter_index": index}, + "open_promise_ids": [], + "closed_promise_ids": [], + } + for index in range(32, 37) + ], + "final_state_snapshot": { + "turn_index": 36, + "open_promises": [], + "metadata": {"closed_promise_ids": ["promise_closed_1", "promise_closed_2"]}, + }, + "longform_summary": { + "series_id": "series_demo", + "target_chapters": 100, + }, + "longform_plan_snapshot": { + "series_plan": { + "series_id": "series_demo", + "title": "长线世界", + "total_chapter_target": 100, + }, + "volume_plans": [ + { + "volume_id": "volume_1", + "order": 1, + "title": "卷一", + "target_chapters": 50, + } + ], + "arc_plans": [ + { + "arc_id": "arc_1", + "volume_id": "volume_1", + "order": 1, + "title": "弧线一", + "target_chapters": 20, + "chapter_tasks": [{"chapter_task_id": "task_1"}], + } + ], + }, + } + + drilldown = authoring._build_longform_drilldown(simulation_report) + + assert drilldown["promise_runway_summary"]["runway_status"] == "exhausted" + assert drilldown["midrun_signal_window"]["avg_pacing"] < 0.34 + assert drilldown["midrun_signal_window"]["avg_exposition_ratio"] > 0.5 + assert drilldown["midrun_signal_window"]["scene_family_repeat_ratio"] == 1.0 + assert drilldown["longform_structure_exhaustion"]["key"] == "longform_structure_exhaustion" + assert set(drilldown["longform_structure_exhaustion"]["trigger_issue_codes"]) == {"Q04", "Q09"} + + +def test_authoring_can_persist_promise_state_edit(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_promise_state.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "longform_promise_state_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 promise state 编辑持久化。", + "life_theme": "让 promise 风险可以被作者标注", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=6, + ) + detail = authoring.get_draft(draft["world_version_id"]) + promise_item = detail["promise_state_workbench"]["editable_promises"][0] + task_link = detail["chapter_task_simulation_linking"]["task_links"][0] + updated = authoring.update_promise_state( + draft["world_version_id"], + promise_id=promise_item["promise_id"], + editor_state="defer", + notes="延后到下一条 arc 再回收。", + chapter_index=promise_item["last_seen_chapter"], + chapter_task_id=task_link["chapter_task_id"], + arc_id=task_link["arc_id"], + volume_id=task_link["volume_id"], + ) + + persisted = next( + item + for item in updated["promise_state_workbench"]["editable_promises"] + if item["promise_id"] == promise_item["promise_id"] + ) + assert persisted["editor_state"] == "defer" + assert persisted["editor_notes"] == "延后到下一条 arc 再回收。" + assert updated["promise_state_workbench"]["override_count"] == 1 + assert updated["revision_history"][-1]["source"] == "promise_state_editor" + + +def test_authoring_can_persist_continuity_override(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_continuity_override.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "longform_continuity_override_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 continuity override 持久化。", + "life_theme": "让作者能标记刻意漂移与接受的权衡", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=6, + ) + detail = authoring.get_draft(draft["world_version_id"]) + candidate = detail["continuity_override_workbench"]["candidate_chapters"][0] + updated = authoring.update_continuity_override( + draft["world_version_id"], + chapter_index=int(candidate["chapter_index"]), + override_state="intentional", + notes="这是刻意保留的漂移,下一次 payoff 会解释。", + issue_scope=list(candidate.get("issue_codes", [])), + chapter_task_id=candidate.get("chapter_task_id"), + arc_id=candidate.get("arc_id"), + volume_id=candidate.get("volume_id"), + ) + persisted = next( + item + for item in updated["continuity_override_workbench"]["candidate_chapters"] + if int(item["chapter_index"]) == int(candidate["chapter_index"]) + ) + assert persisted["override_state"] == "intentional" + assert persisted["override_notes"] == "这是刻意保留的漂移,下一次 payoff 会解释。" + assert updated["continuity_override_workbench"]["override_count"] == 1 + assert updated["revision_history"][-1]["source"] == "continuity_override_editor" + + +def test_task_level_compare_diff_is_exposed_after_resimulation(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_task_compare.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "longform_task_compare_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 task-level compare diff。", + "life_theme": "让任务编辑和章节对照真正连起来", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=6, + ) + detail = authoring.get_draft(draft["world_version_id"]) + worldpack = detail["worldpack"] + worldpack["arc_plans"][0]["chapter_tasks"][0]["objective"] = "把第一条 task 改成更明显的关系推进。" + authoring.update_draft( + draft["world_version_id"], + worldpack, + change_context={"source": "longform_editor", "label": "更新 task 以触发 compare"}, + ) + authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=6, + ) + refreshed = authoring.get_draft(draft["world_version_id"]) + first_task = refreshed["chapter_task_simulation_linking"]["task_links"][0] + assert "compare_summary" in first_task + assert "compare_chapters" in first_task + assert first_task["compare_summary"]["compared_chapter_count"] >= 0 + assert "promise_drift" in first_task + assert "planned_only_ids" in first_task["promise_drift"] + assert "observed_only_ids" in first_task["promise_drift"] + assert "remediation_suggestions" in first_task + assert "rewrite_workflow" in first_task + assert refreshed["simulation_diff_checkpoint"]["available"] is True + + +def test_simulation_diff_checkpoint_marks_pending_resimulation_after_task_edit(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_checkpoint_pending.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "longform_checkpoint_pending_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 checkpoint 在 task 编辑后提示待重跑。", + "life_theme": "让 rewrite 后的状态更可见", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=6, + ) + detail = authoring.get_draft(draft["world_version_id"]) + worldpack = detail["worldpack"] + worldpack["arc_plans"][0]["chapter_tasks"][0]["objective"] = "把这一条改成新的 rewrite 目标。" + authoring.update_draft( + draft["world_version_id"], + worldpack, + change_context={"source": "longform_editor", "label": "更新 task 触发 checkpoint"}, + ) + refreshed = authoring.get_draft(draft["world_version_id"]) + assert refreshed["simulation_diff_checkpoint"]["status"] == "pending_resimulation" + assert refreshed["simulation_diff_checkpoint"]["auto_resimulate_suggested"] is True + assert refreshed["simulation_diff_checkpoint"]["suggested_action"] == "simulate_draft" + + +def test_authoring_can_bulk_apply_task_to_simulation(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_task_bulk_apply.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "longform_task_bulk_apply_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 task bulk apply 到 simulation。", + "life_theme": "让一条 task 对应的章节可一次性标记", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=6, + ) + detail = authoring.get_draft(draft["world_version_id"]) + first_task = detail["chapter_task_simulation_linking"]["task_links"][0] + chapter_indices = [int(item["chapter_index"]) for item in first_task["linked_chapters"]] + updated = authoring.bulk_apply_task_continuity_override( + draft["world_version_id"], + chapter_indices=chapter_indices, + override_state="needs_rewrite", + notes="这一组章节统一重写。", + issue_scope=["Q06", "Q07"], + chapter_task_id=first_task["chapter_task_id"], + arc_id=first_task["arc_id"], + volume_id=first_task["volume_id"], + ) + overrides = { + int(item["chapter_index"]): item + for item in updated["continuity_override_workbench"]["candidate_chapters"] + if item["override_state"] == "needs_rewrite" + } + assert chapter_indices + assert all(index in overrides for index in chapter_indices) + assert updated["revision_history"][-1]["source"] == "task_bulk_apply" + + +def test_task_promise_drift_reports_planned_only_ids(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_task_promise_drift.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "longform_task_promise_drift_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 task promise drift。", + "life_theme": "让计划中的 promise 目标和实际观测能对照出来", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + detail = authoring.get_draft(draft["world_version_id"]) + worldpack = detail["worldpack"] + worldpack["arc_plans"][0]["chapter_tasks"][0]["promise_targets"] = ["promise_planned_only"] + authoring.update_draft( + draft["world_version_id"], + worldpack, + change_context={"source": "longform_editor", "label": "设置 promise targets"}, + ) + authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=6, + ) + refreshed = authoring.get_draft(draft["world_version_id"]) + first_task = refreshed["chapter_task_simulation_linking"]["task_links"][0] + assert first_task["promise_targets"] == ["promise_planned_only"] + assert first_task["promise_drift"]["status"] in {"planned_only", "diverged"} + assert "promise_planned_only" in first_task["promise_drift"]["planned_only_ids"] + assert first_task["promise_drift"]["recommended_actions"] + assert first_task["remediation_suggestions"] + assert first_task["rewrite_workflow"]["available"] is True + + +def test_benchmark_supports_longform_100_mode(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_benchmark.db")) + + def simulation_runner(_world_id: str, world_version_id: str): + return { + "world_version_id": world_version_id, + "world_id": "synthetic_min_pack", + "completed_chapters": 100, + "chapter_budget": 100, + "completion_ratio": 1.0, + "stop_reason": "chapter_budget_reached", + "min_end_turn_target": 90, + "chapter_evaluations": [], + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "top_issue_categories": [], + }, + "longform_summary": { + "character_drift_rate": 0.04, + "promise_unresolved_rate": 0.08, + "arc_task_repeat_rate": 0.12, + "q09_incidence_rate": 0.02, + "premature_ending_trigger_rate": 0.0, + "volume_climax_spacing_error": 0.05, + }, + } + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="synthetic_min_pack", + simulation_runner=simulation_runner, + benchmark_mode="longform_100", + max_chapters=12, + ) + + assert report["benchmark_mode"] == "longform_100" + assert report["chapter_budget"] == 100 + assert report["longform_summary"]["target_chapters"] == 100 + assert report["longform_summary"]["gate_pass_rate"] == 1.0 + assert report["longform_summary"]["q09_incidence_rate"] == 0.02 + assert report["longform_gate"]["passed_world_count"] == 1 + assert "calibration" in report["longform_gate"] + assert report["worlds"][0]["longform_gate"]["passed"] is True + assert report["worlds"][0]["character_drift_rate"] == 0.04 + assert report["worlds"][0]["premature_ending_trigger_rate"] == 0.0 + + +def test_authoring_simulation_supports_interactive_steering(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "interactive_longform_sim.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "interactive_longform_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 interactive steering simulation。", + "life_theme": "让 reader 引导仍能稳定生成后续章节", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + report = authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=12, + interactive_scenarios=[ + { + "scenario_id": "memory_steer", + "scenario_kind": "memory_steer", + "trigger_chapter": 4, + "label": "reader 补记忆", + "steering_directive": { + "memory_patch_note": "角色突然想起一段旧事,影响后续选择。", + "impacted_character_ids": ["lead"], + }, + } + ], + ) + assert report["steering_checkpoints"] + assert report["replan_history"] + assert report["memory_patch_summary"]["pending_count"] >= 0 + assert report["interactive_summary"]["scenario_count"] == 1 + assert "steering_recovery_rate" in report["interactive_summary"] + assert report["creative_cockpit"]["available"] is True + assert report["creative_cockpit"]["steering_timeline"]["checkpoint_count"] == 1 + assert "relationship_network" in report["creative_cockpit"] + assert "impacted_character_ids" in report["creative_cockpit"]["steering_timeline"]["entries"][0] + assert "chapter_task_id" in report["creative_cockpit"]["chapter_heatmap"]["chapters"][0] + assert "scene_id" in report["creative_cockpit"]["chapter_heatmap"]["chapters"][0] + + detail = authoring.get_draft(draft["world_version_id"]) + assert detail["creative_cockpit"]["steering_timeline"]["checkpoint_count"] == 1 + assert "chapter_heatmap" in detail["creative_cockpit"] + + +def test_creative_cockpit_groups_heatmap_hotspots_into_asset_priorities(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "creative_cockpit_issue_groups.db")) + authoring = AuthoringService(repository) + cockpit = authoring._build_creative_cockpit( + { + "characters": [ + {"character_id": "lead", "display_name": "甲", "role": "lead"}, + {"character_id": "counterpart", "display_name": "乙", "role": "counterpart"}, + ], + "scene_blueprints": [ + { + "scene_id": "scene_false_peace", + "scene_function": "false_peace", + "required_roles": ["lead", "counterpart"], + } + ], + }, + { + "final_state_snapshot": { + "relationship_graph": [], + "characters": {}, + "volume_memory_snapshots": [], + "series_memory_snapshots": [], + "series_ending_checkpoint": {}, + "replan_stability_metrics": {}, + }, + "simulation_drilldown": { + "chapter_breakdown": [ + {"chapter_index": 1, "chapter_title": "第1章", "decision": "rewrite", "overall_score": 0.41, "issue_codes": ["Q03"], "scene_function": "false_peace"}, + {"chapter_index": 2, "chapter_title": "第2章", "decision": "rewrite", "overall_score": 0.38, "issue_codes": ["Q04"], "scene_function": "false_peace"}, + {"chapter_index": 3, "chapter_title": "第3章", "decision": "rewrite", "overall_score": 0.35, "issue_codes": ["Q05"], "scene_function": "false_peace"}, + {"chapter_index": 4, "chapter_title": "第4章", "decision": "block", "overall_score": 0.21, "issue_codes": ["Q09"], "scene_function": "false_peace"}, + ], + "decision_histogram": {"rewrite": 3, "block": 1}, + }, + "chapter_trace": [ + { + "chapter_id": "chapter_1", + "scene_function": "false_peace", + "chapter_task": {"chapter_task_id": "task_1"}, + "arc_id": "arc_1", + "volume_id": "volume_1", + "chapter_task_execution_summary": {"series_chapter_index": 1}, + }, + { + "chapter_id": "chapter_2", + "scene_function": "false_peace", + "chapter_task": {"chapter_task_id": "task_2"}, + "arc_id": "arc_1", + "volume_id": "volume_1", + "chapter_task_execution_summary": {"series_chapter_index": 2}, + }, + { + "chapter_id": "chapter_3", + "scene_function": "false_peace", + "chapter_task": {"chapter_task_id": "task_3"}, + "arc_id": "arc_1", + "volume_id": "volume_1", + "chapter_task_execution_summary": {"series_chapter_index": 3}, + }, + { + "chapter_id": "chapter_4", + "scene_function": "false_peace", + "chapter_task": {"chapter_task_id": "task_4"}, + "arc_id": "arc_1", + "volume_id": "volume_1", + "chapter_task_execution_summary": {"series_chapter_index": 4}, + }, + ], + "longform_plan_snapshot": { + "volume_plans": [{"volume_id": "volume_1", "order": 1, "title": "卷一", "target_chapters": 4}], + "arc_plans": [{"arc_id": "arc_1", "volume_id": "volume_1", "order": 1, "title": "弧线一", "target_chapters": 4, "chapter_tasks": [{"chapter_task_id": "task_1"}]}], + }, + }, + ) + groups = {item["issue_code"]: item for item in cockpit["chapter_heatmap"]["issue_priority_groups"]} + assert groups["Q03"]["primary_asset_type"] == "scene_blueprint" + assert groups["Q03"]["primary_validation_panel"] == "compare" + assert groups["Q04"]["asset_priorities"][1]["asset_type"] == "character_card" + assert groups["Q04"]["asset_priorities"][1]["validation_panel"] == "continuity" + assert groups["Q05"]["asset_priorities"][1]["available"] is True + assert groups["Q09"]["primary_asset_type"] == "chapter_task" + assert groups["Q09"]["primary_asset"]["validation_panel"] == "task_linking" + + +def test_repair_loop_outcome_compares_latest_issue_group_against_prior_simulation(): + repository = SQLAlchemyRepository(database_url="sqlite://") + authoring = AuthoringService(repository) + revisions = [ + { + "revision_id": "rev_base", + "simulation_snapshot": { + "chapter_snapshots": [ + {"chapter_index": 1, "chapter_title": "第1章", "decision": "rewrite", "issue_codes": ["Q05"]}, + {"chapter_index": 2, "chapter_title": "第2章", "decision": "block", "issue_codes": ["Q05", "Q09"]}, + ] + }, + }, + { + "revision_id": "rev_fix", + "repair_loop_context": { + "issue_code": "Q05", + "issue_label": "lack of scene detail", + "asset_type": "scene_blueprint", + "asset_label": "场景蓝图", + "target_label": "scene_false_peace", + "validation_panel": "compare", + "validation_panel_label": "Compare", + "validation_reason": "改完 scene 后回 Compare 看前后章节差异。", + "scene_id": "scene_false_peace", + "scene_function": "false_peace", + "chapter_index": 1, + "chapter_title": "第1章", + "targeted_chapter_indices": [1, 2], + "window_label": "early", + "window_breach_kind": "early_window_q03_q04_share", + "contract_failed_checks": ["detail_density_floor"], + }, + }, + ] + outcome = authoring._build_repair_loop_outcome( + revisions, + current_issue_groups=[ + { + "issue_code": "Q05", + "chapter_count": 1, + "primary_asset_type": "scene_blueprint", + } + ], + current_chapter_heatmap=[ + { + "chapter_index": 2, + "chapter_title": "第2章", + "decision": "rewrite", + "issue_count": 1, + "issue_codes": ["Q05"], + } + ], + ) + assert outcome["repair_loop_revision_id"] == "rev_fix" + assert outcome["baseline_issue_count"] == 2 + assert outcome["current_issue_count"] == 1 + assert outcome["count_delta"] == -1 + assert outcome["baseline_worst_decision"] == "block" + assert outcome["current_worst_decision"] == "rewrite" + assert outcome["window_label"] == "early" + assert outcome["window_breach_kind"] == "early_window_q03_q04_share" + assert outcome["baseline_window_issue_count"] == 2 + assert outcome["current_window_issue_count"] == 1 + assert outcome["baseline_window_worst_decision"] == "block" + assert outcome["current_window_worst_decision"] == "rewrite" + assert outcome["severity_trend"] == "improved" + assert outcome["ready_for_validation"] is True + assert outcome["resolved_chapters"][0]["chapter_index"] == 1 + assert outcome["resolved_window_chapters"][0]["chapter_index"] == 1 + + +def test_authoring_simulation_snapshots_final_completed_volume(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "final_volume_snapshot.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "volume_snapshot_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 final volume snapshot。", + "life_theme": "长线推进不漏掉最终卷快照", + "locations": "中庭\n长廊\n窗边", + "author_id": "acct_longform", + "target_total_chapters": 12, + "target_total_volumes": 3, + "target_word_count": 24000, + } + ) + report = authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=12, + ) + snapshots = list((report.get("final_state_snapshot") or {}).get("volume_memory_snapshots", [])) + snapshot_volume_ids = {str(item.get("volume_id") or "") for item in snapshots if str(item.get("volume_id") or "")} + assert report["completed_chapters"] == 12 + assert len(snapshot_volume_ids) == 3 + assert report["longform_summary"]["target_chapters"] == 12 + + +def test_authoring_simulation_builds_series_snapshots_and_ending_checkpoint(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "series_snapshot_world.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "series_snapshot_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 series-level compression 和 ending checkpoint。", + "life_theme": "长线推进与结局控制", + "author_id": "acct_longform", + "target_total_chapters": 40, + "target_total_volumes": 4, + "target_word_count": 80000, + } + ) + report = authoring.run_simulation_for_world_version( + draft["world_version_id"], + include_cross_pack=False, + max_chapters=40, + ) + final_state = dict(report.get("final_state_snapshot") or {}) + series_snapshots = list(final_state.get("series_memory_snapshots", [])) + series_ending_checkpoint = dict(final_state.get("series_ending_checkpoint", {})) + + assert len(series_snapshots) >= 2 + assert series_ending_checkpoint["terminal_ready"] is True + assert series_ending_checkpoint["status"] == "ready" + + +def test_series_snapshot_prunes_archive_memory_when_policy_is_tight(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "series_archive_prune.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "series_archive_prune_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 series snapshot 会剪掉已被总结覆盖的 archive memory。", + "life_theme": "长线压缩", + "author_id": "acct_longform", + "target_total_chapters": 40, + "target_total_volumes": 4, + "target_word_count": 80000, + } + ) + detail = authoring.get_draft(draft["world_version_id"]) + worldpack = dict(detail["worldpack"]) + worldpack["memory_compression_policy"] = { + **dict(worldpack.get("memory_compression_policy") or {}), + "series_snapshot_every_n_volumes": 1, + "archive_retention_limit": 5, + "series_archive_prune_margin_chapters": 0, + "timeline_retention_limit": 40, + "continuation_fact_retention_limit": 40, + "continuation_visit_retention_limit": 40, + } + updated = authoring.save_draft( + worldpack, + change_context={"source": "test", "label": "tight archive prune policy"}, + ) + report = authoring.run_simulation_for_world_version( + updated["world_version_id"], + include_cross_pack=False, + max_chapters=40, + ) + final_state = dict(report.get("final_state_snapshot") or {}) + assert len(final_state.get("series_memory_snapshots", [])) >= 1 + assert len(final_state.get("archive_memory", [])) <= 5 + assert len(final_state.get("timeline", [])) <= 40 + continuation_facts = [item for item in final_state.get("world_facts", []) if str(item).startswith("continuation::")] + continuation_event_ids = [item for item in final_state.get("visited_event_ids", []) if "__continuation__" in str(item)] + assert len(continuation_facts) <= 40 + assert len(continuation_event_ids) <= 40 + + +def test_benchmark_supports_longform_100_interactive_mode(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "interactive_benchmark.db")) + + def simulation_runner(_world_id: str, world_version_id: str, scenarios): + assert scenarios + return { + "world_version_id": world_version_id, + "world_id": "synthetic_min_pack", + "completed_chapters": 100, + "chapter_budget": 100, + "completion_ratio": 1.0, + "stop_reason": "chapter_budget_reached", + "min_end_turn_target": 90, + "chapter_evaluations": [], + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "top_issue_categories": [], + }, + "longform_summary": { + "character_drift_rate": 0.02, + "promise_unresolved_rate": 0.1, + "arc_task_repeat_rate": 0.05, + "q09_incidence_rate": 0.01, + "premature_ending_trigger_rate": 0.0, + "volume_climax_spacing_error": 0.04, + }, + "longform_gate": {"passed": True}, + "interactive_summary": { + "scenario_count": 3, + "steering_recovery_rate": 1.0, + "post_steer_route_survival": 0.9, + "memory_consistency_after_steer": 0.9, + "promise_reconciliation_after_steer": 0.85, + "replan_stability_score": 0.9, + }, + } + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="synthetic_min_pack", + simulation_runner=simulation_runner, + benchmark_mode="longform_100_interactive", + max_chapters=100, + ) + + assert report["benchmark_mode"] == "longform_100_interactive" + assert report["interactive_longform_gate"]["pass_rate"] == 1.0 + assert report["interactive_longform_signoff"]["status"] == "watch" + + +def test_benchmark_supports_longform_250_mode(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_250_benchmark.db")) + + def simulation_runner(_world_id: str, world_version_id: str): + return { + "world_version_id": world_version_id, + "world_id": "synthetic_min_pack", + "completed_chapters": 250, + "chapter_budget": 250, + "completion_ratio": 1.0, + "stop_reason": "chapter_budget_reached", + "min_end_turn_target": 90, + "chapter_evaluations": [], + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "top_issue_categories": [], + }, + "longform_summary": { + "character_drift_rate": 0.01, + "promise_unresolved_rate": 0.08, + "arc_task_repeat_rate": 0.03, + "q09_incidence_rate": 0.0, + "premature_ending_trigger_rate": 0.0, + "volume_climax_spacing_error": 0.02, + }, + "longform_250_summary": { + "target_chapters": 250, + "target_volume_count": 5, + "completed_volume_count": 5, + "volume_boundary_survival": 1.0, + "memory_recall_coverage": 0.9, + "replan_stability_score": 0.95, + "volume_snapshot_integrity": 1.0, + "mid_volume_pass_rate": 1.0, + "late_volume_pass_rate": 1.0, + }, + "longform_250_evidence": { + "status": "ready", + "failed_checks": [], + }, + } + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="synthetic_min_pack", + simulation_runner=simulation_runner, + benchmark_mode="longform_250", + max_chapters=250, + ) + + assert report["benchmark_mode"] == "longform_250" + assert report["longform_250_summary"]["gate_pass_rate"] == 1.0 + assert "review_sample_coverage_250" in report + assert "longform_250_signoff" in report + + +def test_benchmark_supports_longform_250_interactive_mode(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_250_interactive_benchmark.db")) + + def simulation_runner(_world_id: str, world_version_id: str, scenarios): + assert scenarios + return { + "world_version_id": world_version_id, + "world_id": "synthetic_min_pack", + "completed_chapters": 250, + "chapter_budget": 250, + "completion_ratio": 1.0, + "stop_reason": "chapter_budget_reached", + "min_end_turn_target": 180, + "chapter_evaluations": [], + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "top_issue_categories": [], + }, + "longform_summary": { + "character_drift_rate": 0.01, + "promise_unresolved_rate": 0.08, + "arc_task_repeat_rate": 0.03, + "q09_incidence_rate": 0.0, + "premature_ending_trigger_rate": 0.0, + "volume_climax_spacing_error": 0.02, + }, + "longform_250_summary": { + "target_chapters": 250, + "target_volume_count": 5, + "completed_volume_count": 5, + "volume_boundary_survival": 1.0, + "memory_recall_coverage": 0.9, + "replan_stability_score": 0.95, + "volume_snapshot_integrity": 1.0, + "mid_volume_pass_rate": 1.0, + "late_volume_pass_rate": 1.0, + }, + "longform_250_evidence": { + "status": "ready", + "failed_checks": [], + }, + "interactive_summary": { + "scenario_count": 3, + "steering_recovery_rate": 1.0, + "post_steer_route_survival": 0.9, + "memory_consistency_after_steer": 0.9, + "promise_reconciliation_after_steer": 0.85, + "replan_stability_score": 0.9, + }, + } + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="synthetic_min_pack", + simulation_runner=simulation_runner, + benchmark_mode="longform_250_interactive", + max_chapters=250, + execute_review_sampling_250=True, + ) + + assert report["benchmark_mode"] == "longform_250_interactive" + assert report["longform_250_summary"]["gate_pass_rate"] == 1.0 + assert report["longform_250_interactive_gate"]["pass_rate"] == 1.0 + assert "longform_250_interactive_signoff" in report + + +def test_benchmark_supports_longform_500_mode(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_500_benchmark.db")) + + def simulation_runner(_world_id: str, world_version_id: str): + return { + "world_version_id": world_version_id, + "world_id": "synthetic_min_pack", + "completed_chapters": 500, + "chapter_budget": 500, + "completion_ratio": 1.0, + "stop_reason": "chapter_budget_reached", + "min_end_turn_target": 400, + "chapter_evaluations": [], + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "top_issue_categories": [], + }, + "longform_summary": { + "character_drift_rate": 0.01, + "promise_unresolved_rate": 0.08, + "arc_task_repeat_rate": 0.03, + "q09_incidence_rate": 0.0, + "premature_ending_trigger_rate": 0.0, + "volume_climax_spacing_error": 0.02, + }, + "longform_500_summary": { + "target_chapters": 500, + "series_boundary_survival": 1.0, + "series_memory_snapshot_integrity": 1.0, + "memory_recall_coverage": 1.0, + "replan_stability_score": 0.9, + "late_series_pass_rate": 1.0, + "series_ending_control_score": 1.0, + }, + "longform_500_evidence": { + "status": "ready", + "failed_checks": [], + }, + } + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="synthetic_min_pack", + simulation_runner=simulation_runner, + benchmark_mode="longform_500", + max_chapters=500, + ) + + assert report["benchmark_mode"] == "longform_500" + assert report["longform_500_summary"]["gate_pass_rate"] == 1.0 + assert report["longform_500_signoff"]["status"] == "watch" + assert report["longform_500_human_review_closeout"]["status"] == "watch" + assert report["longform_500_ending_signoff"]["status"] == "watch" + + +def test_benchmark_supports_longform_500_interactive_mode(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_500_interactive_benchmark.db")) + + def simulation_runner(_world_id: str, world_version_id: str, _interactive_scenarios=None): + return { + "world_version_id": world_version_id, + "world_id": "synthetic_min_pack", + "completed_chapters": 500, + "chapter_budget": 500, + "completion_ratio": 1.0, + "stop_reason": "chapter_budget_reached", + "min_end_turn_target": 400, + "chapter_evaluations": [ + { + "chapter_id": f"simulation_{world_version_id}_{chapter_index}", + "world_version_id": world_version_id, + "session_id": "simulation:synthetic_min_pack", + "decision": {"decision": "pass", "reason": "benchmark"}, + "issues": [], + "scores": { + "readability": 0.9, + "scene_density": 0.9, + "character_fidelity": 0.9, + "causal_continuity": 0.9, + "pacing": 0.9, + "choice_distinctness": 0.9, + "hook_quality": 0.9, + "monetize_ready": 0.9, + "overall_score": 0.9, + }, + "hard_validator_results": {}, + "summary": f"chapter {chapter_index}", + "created_at": "2026-04-06T00:00:00+00:00", + } + for chapter_index in [1, 21, 220, 260, 460, 480] + ], + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "top_issue_categories": [], + }, + "longform_summary": { + "character_drift_rate": 0.01, + "promise_unresolved_rate": 0.08, + "arc_task_repeat_rate": 0.03, + "q09_incidence_rate": 0.0, + "premature_ending_trigger_rate": 0.0, + "volume_climax_spacing_error": 0.02, + }, + "longform_500_summary": { + "target_chapters": 500, + "series_boundary_survival": 1.0, + "series_memory_snapshot_integrity": 1.0, + "memory_recall_coverage": 1.0, + "replan_stability_score": 0.9, + "late_series_pass_rate": 1.0, + "series_ending_control_score": 1.0, + }, + "longform_500_evidence": { + "status": "ready", + "failed_checks": [], + }, + "interactive_summary": { + "scenario_count": 3, + "steering_recovery_rate": 1.0, + "post_steer_route_survival": 0.9, + "memory_consistency_after_steer": 0.95, + "promise_reconciliation_after_steer": 0.9, + "replan_stability_score": 0.9, + }, + } + + for chapter_index in [1, 21, 220, 260, 460, 480]: + TrainingSignalService(repository).save_review_sample( + { + "chapter_id": f"simulation_synthetic_min_pack@0.1.0_{chapter_index}", + "world_id": "synthetic_min_pack", + "world_version_id": "synthetic_min_pack@0.1.0", + "reviewer_id": "ops_human_closeout", + "score_overall": 0.9, + "issue_codes": [], + "linked_issue_codes": [], + "freeform_notes": "interactive 500 closeout", + "would_continue": True, + "would_pay": True, + "source": "human_review", + "source_ref": {"kind": "manual_entry", "chapter_id": f"simulation_synthetic_min_pack@0.1.0_{chapter_index}"}, + } + ) + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="synthetic_min_pack", + simulation_runner=simulation_runner, + benchmark_mode="longform_500_interactive", + max_chapters=500, + ) + + assert report["benchmark_mode"] == "longform_500_interactive" + assert report["longform_500_summary"]["gate_pass_rate"] == 1.0 + assert report["longform_500_interactive_gate"]["pass_rate"] == 1.0 + assert report["longform_500_human_review_closeout"]["status"] == "watch" + assert report["longform_500_ending_signoff"]["status"] == "watch" + assert report["longform_500_interactive_signoff"]["status"] == "watch" + + +def test_benchmark_supports_longform_1000_diagnostics_mode(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_1000_diagnostics.db")) + + def simulation_runner(_world_id: str, world_version_id: str): + return { + "world_version_id": world_version_id, + "world_id": "synthetic_min_pack", + "completed_chapters": 1000, + "chapter_budget": 1000, + "completion_ratio": 1.0, + "stop_reason": "chapter_budget_reached", + "min_end_turn_target": 800, + "chapter_evaluations": [], + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "top_issue_categories": [], + }, + "longform_1000_summary": { + "target_chapters": 1000, + "series_boundary_survival": 1.0, + "series_memory_snapshot_integrity": 1.0, + "memory_recall_coverage": 1.0, + "replan_stability_score": 0.9, + "archive_retention_integrity": 1.0, + "timeline_retention_integrity": 1.0, + "continuation_state_retention_integrity": 1.0, + "late_stage_runtime_p95_ms": 1800.0, + "late_stage_runtime_budget_score": 1.0, + "series_ending_control_score": 1.0, + }, + "longform_1000_evidence": { + "status": "promising", + "failed_checks": [], + }, + } + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="synthetic_min_pack", + simulation_runner=simulation_runner, + benchmark_mode="longform_1000_diagnostics", + max_chapters=1000, + ) + + assert report["benchmark_mode"] == "longform_1000_diagnostics" + assert report["longform_1000_summary"]["diagnostic_pass_rate"] == 1.0 + assert report["longform_1000_feasibility"]["status"] == "watch" + assert report["longform_1000_readiness"]["status"] == "watch" + assert report["longform_1000_human_review_closeout"]["status"] == "watch" + + +def test_benchmark_supports_longform_1000_interactive_mode(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_1000_interactive.db")) + + def simulation_runner(_world_id: str, world_version_id: str, _interactive_scenarios=None): + return { + "world_version_id": world_version_id, + "world_id": "synthetic_min_pack", + "completed_chapters": 1000, + "chapter_budget": 1000, + "completion_ratio": 1.0, + "stop_reason": "chapter_budget_reached", + "min_end_turn_target": 800, + "chapter_evaluations": [ + { + "chapter_id": f"simulation_{world_version_id}_{chapter_index}", + "world_version_id": world_version_id, + "session_id": "simulation:synthetic_min_pack", + "decision": {"decision": "pass", "reason": "benchmark"}, + "issues": [ + { + "issue_code": "Q03", + "severity": "medium", + "summary": "repeat", + "owning_module": "writer", + "evidence": [], + } + ] if chapter_index == 220 else [], + "scores": { + "readability": 0.9, + "scene_density": 0.9, + "character_fidelity": 0.9, + "causal_continuity": 0.9, + "pacing": 0.9, + "choice_distinctness": 0.9, + "hook_quality": 0.9, + "monetize_ready": 0.9, + "overall_score": 0.9, + }, + "hard_validator_results": {}, + "summary": f"chapter {chapter_index}", + "created_at": "2026-04-06T00:00:00+00:00", + } + for chapter_index in [1, 40, 420, 500, 920, 960] + ], + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "top_issue_categories": [], + }, + "longform_1000_summary": { + "target_chapters": 1000, + "series_boundary_survival": 1.0, + "series_memory_snapshot_integrity": 1.0, + "memory_recall_coverage": 1.0, + "replan_stability_score": 0.9, + "archive_retention_integrity": 1.0, + "timeline_retention_integrity": 1.0, + "continuation_state_retention_integrity": 1.0, + "late_stage_runtime_p95_ms": 1200.0, + "late_stage_runtime_budget_score": 1.0, + "series_ending_control_score": 1.0, + }, + "longform_1000_evidence": { + "status": "promising", + "failed_checks": [], + }, + "interactive_summary": { + "scenario_count": 3, + "steering_recovery_rate": 1.0, + "post_steer_route_survival": 0.95, + "memory_consistency_after_steer": 0.96, + "promise_reconciliation_after_steer": 0.94, + "replan_stability_score": 0.91, + }, + } + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="synthetic_min_pack", + simulation_runner=simulation_runner, + benchmark_mode="longform_1000_interactive", + max_chapters=1000, + ) + + assert report["benchmark_mode"] == "longform_1000_interactive" + assert report["longform_1000_summary"]["diagnostic_pass_rate"] == 1.0 + assert report["longform_1000_interactive_gate"]["pass_rate"] == 1.0 + assert report["longform_1000_readiness"]["status"] == "watch" + assert report["longform_1000_interactive_signoff"]["status"] == "watch" + assert report["longform_1000_human_review_closeout"]["status"] == "watch" + + +def test_benchmark_can_execute_longform_250_review_sampling_closeout(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_250_review_sampling.db")) + + def chapter_report(*, chapter_id: str, overall_score: float, issue_codes: list[str], detail_density: float) -> dict[str, object]: + return { + "chapter_id": chapter_id, + "world_version_id": "synthetic_min_pack@0.1.0", + "session_id": "simulation:synthetic_min_pack", + "decision": {"decision": "pass", "reason": "benchmark"}, + "issues": [ + { + "issue_code": code, + "severity": "medium", + "summary": code, + "owning_module": "writer", + "evidence": [], + } + for code in issue_codes + ], + "scores": { + "readability": overall_score, + "scene_density": overall_score, + "character_fidelity": overall_score, + "causal_continuity": overall_score, + "pacing": overall_score, + "choice_distinctness": overall_score, + "hook_quality": overall_score, + "monetize_ready": overall_score, + "overall_score": overall_score, + }, + "hard_validator_results": { + "lint_metrics": { + "engineering_leak_rate": 0.0, + "dialogue_plus_action_ratio": 0.6, + "concrete_detail_density": detail_density, + "repetition_score": 0.0, + "exposition_ratio": 0.2, + } + }, + "summary": "synthetic benchmark report", + "created_at": "2026-04-06T00:00:00Z", + } + + def simulation_runner(_world_id: str, world_version_id: str): + chapter_indices = [1, 10, 80, 100, 200, 225] + return { + "world_version_id": world_version_id, + "world_id": "synthetic_min_pack", + "completed_chapters": 250, + "chapter_budget": 250, + "completion_ratio": 1.0, + "stop_reason": "chapter_budget_reached", + "min_end_turn_target": 90, + "chapter_evaluations": [ + chapter_report( + chapter_id=f"simulation_{world_version_id}_{chapter_index}", + overall_score=0.86, + issue_codes=["Q05"] if chapter_index in {80, 200} else [], + detail_density=0.06, + ) + for chapter_index in chapter_indices + ], + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "top_issue_categories": [{"issue_code": "Q05", "count": 2}], + }, + "longform_summary": { + "character_drift_rate": 0.01, + "promise_unresolved_rate": 0.08, + "arc_task_repeat_rate": 0.03, + "q09_incidence_rate": 0.0, + "premature_ending_trigger_rate": 0.0, + "volume_climax_spacing_error": 0.02, + }, + "longform_250_summary": { + "target_chapters": 250, + "target_volume_count": 5, + "completed_volume_count": 5, + "volume_boundary_survival": 1.0, + "memory_recall_coverage": 0.9, + "replan_stability_score": 0.95, + "volume_snapshot_integrity": 1.0, + "mid_volume_pass_rate": 1.0, + "late_volume_pass_rate": 1.0, + }, + "longform_250_evidence": { + "status": "ready", + "failed_checks": [], + }, + } + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="synthetic_min_pack", + simulation_runner=simulation_runner, + benchmark_mode="longform_250", + max_chapters=250, + execute_review_sampling_250=True, + ) + + coverage = report["review_sample_coverage_250"] + assert coverage["planned_target_count"] == 6 + assert coverage["executed_target_count"] == 6 + assert coverage["auto_seeded_target_count"] == 6 + assert coverage["closeout_ready"] is True + assert coverage["closeout_status"] == "closed_with_auto_seed" + assert coverage["human_closeout_ready"] is False + assert coverage["human_closeout_status"] == "watch" + assert len(coverage["human_unreviewed_targets"]) == 6 + assert not coverage["unreviewed_targets"] + saved_samples = TrainingSignalService(repository).list_review_samples(world_id="synthetic_min_pack") + assert len(saved_samples) == 6 + + +def test_benchmark_can_execute_longform_500_review_and_human_closeout(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_500_review_sampling.db")) + + def simulation_runner(_world_id: str, world_version_id: str): + chapter_indices = [1, 21, 220, 260, 460, 480] + chapter_evaluations = [] + for chapter_index in chapter_indices: + repetition_score = 0.21 if chapter_index == 220 else 0.04 + issue_codes = ["Q03"] if chapter_index == 220 else [] + chapter_evaluations.append( + { + "chapter_id": f"simulation_{world_version_id}_{chapter_index}", + "world_version_id": world_version_id, + "session_id": "simulation:synthetic_min_pack", + "decision": {"decision": "pass", "reason": "benchmark"}, + "issues": [ + { + "issue_code": code, + "severity": "medium", + "summary": code, + "owning_module": "writer", + "evidence": [], + } + for code in issue_codes + ], + "scores": { + "readability": 0.9, + "scene_density": 0.9, + "character_fidelity": 0.9, + "causal_continuity": 0.9, + "pacing": 0.9, + "choice_distinctness": 0.9, + "hook_quality": 0.9, + "monetize_ready": 0.9, + "overall_score": 0.9, + }, + "hard_validator_results": { + "lint_metrics": { + "engineering_leak_rate": 0.0, + "dialogue_plus_action_ratio": 0.55, + "concrete_detail_density": 0.06, + "repetition_score": repetition_score, + "exposition_ratio": 0.2, + "repetition_signal_bundle": { + "semantic_paragraph_similarity_score": 0.1, + "event_coverage_gap_score": 0.0, + "beat_coverage_gap_score": 0.0, + "uncovered_beat_count": 0, + "overcovered_beat_count": 0, + }, + } + }, + "summary": f"chapter {chapter_index}", + "created_at": "2026-04-06T00:00:00Z", + } + ) + return { + "world_version_id": world_version_id, + "world_id": "synthetic_min_pack", + "completed_chapters": 500, + "chapter_budget": 500, + "completion_ratio": 1.0, + "stop_reason": "chapter_budget_reached", + "min_end_turn_target": 400, + "chapter_evaluations": chapter_evaluations, + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "top_issue_categories": [{"issue_code": "Q03", "count": 1}], + }, + "longform_summary": { + "character_drift_rate": 0.01, + "promise_unresolved_rate": 0.08, + "arc_task_repeat_rate": 0.03, + "q09_incidence_rate": 0.0, + "premature_ending_trigger_rate": 0.0, + "volume_climax_spacing_error": 0.02, + }, + "longform_500_summary": { + "target_chapters": 500, + "series_boundary_survival": 1.0, + "series_memory_snapshot_integrity": 1.0, + "memory_recall_coverage": 1.0, + "replan_stability_score": 0.9, + "late_series_pass_rate": 1.0, + "series_ending_control_score": 1.0, + }, + "longform_500_evidence": { + "status": "ready", + "failed_checks": [], + }, + } + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="synthetic_min_pack", + simulation_runner=simulation_runner, + benchmark_mode="longform_500", + max_chapters=500, + execute_review_sampling_500=True, + execute_human_review_closeout_500=True, + human_review_closeout_500_reviewer_id="ops_test_longform500", + ) + + coverage = report["review_sample_coverage_500"] + assert coverage["planned_target_count"] == 6 + assert coverage["executed_target_count"] == 6 + assert coverage["auto_seeded_target_count"] == 6 + assert coverage["human_reviewed_target_count"] == 6 + assert coverage["human_closeout_ready"] is True + assert coverage["ending_window_human_closeout_ready"] is True + assert report["longform_500_human_review_closeout"]["reason"] == "benchmark_scope_incomplete" + assert report["longform_500_ending_signoff"]["reason"] == "benchmark_scope_incomplete" + assert report["worlds"][0]["surface_issue_chapters"][0]["chapter_index"] == 220 + assert report["worlds"][0]["surface_issue_chapters"][0]["issue_codes"] == ["Q03"] + saved_samples = TrainingSignalService(repository).list_review_samples(world_id="synthetic_min_pack") + assert len(saved_samples) == 12 + assert sum(1 for sample in saved_samples if sample["source"] == "evaluation_report_auto") == 6 + assert sum(1 for sample in saved_samples if sample["source"] == "human_review") == 6 + + +def test_longform_250_signoff_requires_review_sampling_closeout(): + summary = { + "benchmark_mode": "longform_250", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "weakest_pack_polish_program": {"continue_worlds": []}, + "longform_250_evidence": { + "gate_pass_rate": 1.0, + "failed_worlds": [], + "review_sample_closeout_ready": False, + }, + "review_sample_coverage_250": {"closeout_ready": False}, + "weakest_packs": [{"world_id": "synthetic_min_pack"}], + } + + signoff = build_longform_250_signoff(summary) + + assert signoff["status"] == "watch" + assert signoff["ready"] is False + assert signoff["review_sample_closeout_ready"] is False + + +def test_longform_250_interactive_signoff_requires_static_interactive_and_review_evidence(): + summary = { + "benchmark_mode": "longform_250_interactive", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "weakest_pack_polish_program": {"continue_worlds": []}, + "longform_250_evidence": { + "gate_pass_rate": 1.0, + "failed_worlds": [], + "review_sample_closeout_ready": False, + }, + "longform_250_interactive_gate": { + "pass_rate": 1.0, + "failed_worlds": [], + }, + "review_sample_coverage_250": {"closeout_ready": False}, + "weakest_packs": [{"world_id": "synthetic_min_pack"}], + } + + signoff = build_longform_250_interactive_signoff(summary) + + assert signoff["status"] == "watch" + assert signoff["ready"] is False + assert signoff["review_sample_closeout_ready"] is False + + +def test_longform_250_human_review_closeout_watches_until_human_targets_are_closed(): + summary = { + "benchmark_mode": "longform_250", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "review_sample_coverage_250": { + "planned_target_count": 30, + "human_reviewed_target_count": 0, + "human_closeout_ready": False, + "human_closeout_status": "watch", + "human_unreviewed_targets": [ + {"world_id": "jade_court_romance"}, + {"world_id": "jade_court_exam"}, + ], + }, + "weakest_packs": [{"world_id": "jade_court_romance"}], + } + + signoff = build_longform_250_human_review_closeout(summary) + + assert signoff["status"] == "watch" + assert signoff["ready"] is False + assert signoff["blocking_worlds"] == ["jade_court_exam", "jade_court_romance"] + + +def test_longform_500_signoff_requires_fresh_longform_500_evidence(): + summary = { + "benchmark_mode": "longform_500", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "weakest_pack_polish_program": {"continue_worlds": []}, + "longform_500_evidence": { + "gate_pass_rate": 1.0, + "failed_worlds": [], + }, + "weakest_packs": [{"world_id": "synthetic_min_pack"}], + } + + signoff = build_longform_500_signoff(summary) + + assert signoff["status"] == "ready" + assert signoff["ready"] is True + + +def test_longform_500_human_review_closeout_watches_until_human_targets_are_closed(): + summary = { + "benchmark_mode": "longform_500", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "review_sample_coverage_500": { + "planned_target_count": 30, + "human_reviewed_target_count": 0, + "human_closeout_ready": False, + "human_closeout_status": "watch", + "human_unreviewed_targets": [ + {"world_id": "jade_court_romance", "window_label": "460-500"}, + {"world_id": "jade_court_exam", "window_label": "220-300"}, + ], + }, + "weakest_packs": [{"world_id": "jade_court_romance"}], + } + + signoff = build_longform_500_human_review_closeout(summary) + + assert signoff["status"] == "watch" + assert signoff["ready"] is False + assert signoff["blocking_worlds"] == ["jade_court_exam", "jade_court_romance"] + + +def test_longform_500_ending_signoff_requires_ending_window_human_closeout(): + summary = { + "benchmark_mode": "longform_500", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "longform_500_evidence": { + "gate_pass_rate": 1.0, + "failed_worlds": [], + }, + "review_sample_coverage_500": { + "ending_window_label": "460-500", + "ending_window_human_closeout_ready": False, + "human_unreviewed_targets": [ + {"world_id": "jade_court_romance", "window_label": "460-500"}, + {"world_id": "synthetic_min_pack", "window_label": "1-40"}, + ], + }, + "weakest_packs": [{"world_id": "jade_court_romance"}], + } + + signoff = build_longform_500_ending_signoff(summary) + + assert signoff["status"] == "watch" + assert signoff["ready"] is False + assert signoff["blocking_worlds"] == ["jade_court_romance"] + + +def test_longform_500_interactive_signoff_requires_static_interactive_and_human_closeout(): + summary = { + "benchmark_mode": "longform_500_interactive", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "weakest_pack_polish_program": {"continue_worlds": []}, + "longform_500_evidence": { + "gate_pass_rate": 1.0, + "failed_worlds": [], + }, + "longform_500_interactive_gate": { + "pass_rate": 1.0, + "failed_worlds": [], + }, + "review_sample_coverage_500": { + "human_closeout_ready": False, + "ending_window_human_closeout_ready": False, + }, + "weakest_packs": [{"world_id": "synthetic_min_pack"}], + } + + signoff = build_longform_500_interactive_signoff(summary) + + assert signoff["status"] == "watch" + assert signoff["ready"] is False + assert signoff["human_closeout_ready"] is False + assert signoff["ending_window_human_closeout_ready"] is False + + +def test_longform_500_interactive_signoff_ready_with_all_evidence(): + summary = { + "benchmark_mode": "longform_500_interactive", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "weakest_pack_polish_program": {"continue_worlds": []}, + "longform_500_evidence": { + "gate_pass_rate": 1.0, + "failed_worlds": [], + }, + "longform_500_interactive_gate": { + "pass_rate": 1.0, + "failed_worlds": [], + }, + "review_sample_coverage_500": { + "human_closeout_ready": True, + "ending_window_human_closeout_ready": True, + }, + "weakest_packs": [{"world_id": "synthetic_min_pack"}], + } + + signoff = build_longform_500_interactive_signoff(summary) + + assert signoff["status"] == "ready" + assert signoff["ready"] is True + + +def test_longform_1000_feasibility_promising_with_all_diagnostics(): + summary = { + "benchmark_mode": "longform_1000_diagnostics", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "longform_1000_summary": {"diagnostic_pass_rate": 1.0}, + "longform_1000_evidence": { + "diagnostic_pass_rate": 1.0, + "failed_worlds": [], + }, + "weakest_packs": [{"world_id": "synthetic_min_pack"}], + } + + signoff = build_longform_1000_feasibility(summary) + + assert signoff["status"] == "promising" + assert signoff["ready"] is True + + +def test_longform_1000_readiness_ready_with_feasibility(): + summary = { + "benchmark_mode": "longform_1000_diagnostics", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "longform_1000_summary": {"diagnostic_pass_rate": 1.0}, + "longform_1000_evidence": {"diagnostic_pass_rate": 1.0, "failed_worlds": []}, + "weakest_packs": [{"world_id": "synthetic_min_pack"}], + } + + signoff = build_longform_1000_readiness(summary) + + assert signoff["status"] == "ready" + assert signoff["ready"] is True + + +def test_longform_1000_human_review_closeout_watches_until_human_targets_are_closed(): + summary = { + "benchmark_mode": "longform_1000_diagnostics", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "review_sample_coverage_1000": { + "planned_target_count": 6, + "human_reviewed_target_count": 0, + "human_closeout_ready": False, + "human_closeout_status": "watch", + "human_unreviewed_targets": [ + {"world_id": "jade_court_romance"}, + {"world_id": "jade_court_exam"}, + ], + }, + "weakest_packs": [{"world_id": "jade_court_romance"}], + } + + signoff = build_longform_1000_human_review_closeout(summary) + + assert signoff["status"] == "watch" + assert signoff["ready"] is False + assert signoff["blocking_worlds"] == ["jade_court_exam", "jade_court_romance"] + + +def test_longform_1000_interactive_signoff_requires_static_readiness_and_interactive_gate(): + summary = { + "benchmark_mode": "longform_1000_interactive", + "benchmark_scope_complete": True, + "generated_at": "2026-04-06T00:00:00+00:00", + "longform_1000_readiness": {"status": "ready", "ready": True, "blocking_worlds": [], "watch_worlds": []}, + "longform_1000_interactive_gate": {"pass_rate": 1.0, "failed_worlds": []}, + "weakest_packs": [{"world_id": "synthetic_min_pack"}], + } + + signoff = build_longform_1000_interactive_signoff(summary) + + assert signoff["status"] == "ready" + assert signoff["ready"] is True + + +def test_character_fidelity_remediation_framework_builds_from_simulation_report(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "q06_framework.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "q06_framework_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 Q06 remediation framework。", + "life_theme": "角色一致性", + "author_id": "acct_longform", + "target_total_chapters": 100, + "target_total_volumes": 5, + "target_word_count": 200000, + } + ) + version = repository.get_world_version(draft["world_version_id"]) + version.simulation_report_json = { + "chapter_trace": [ + { + "chapter_id": f"simulation_{draft['world_version_id']}_1", + "chapter_title": "第一章", + "scene_function": "truth_trial", + "actor_ids": ["lead", "counterpart"], + "chapter_task": {"chapter_task_id": "task_1", "duty_type": "advance_plot"}, + "chapter_task_execution_summary": {"series_chapter_index": 1}, + } + ], + "chapter_evaluations": [ + { + "chapter_id": f"simulation_{draft['world_version_id']}_1", + "issues": [{"issue_code": "Q06"}], + "scores": {"character_fidelity": 0.22}, + } + ], + } + repository.save_world_version(version, publish=False) + + detail = authoring.get_draft(draft["world_version_id"]) + framework = detail["character_fidelity_remediation_framework"] + + assert framework["available"] is True + assert framework["q06_chapter_count"] == 1 + assert framework["top_character_hotspots"][0]["character_id"] == "counterpart" or framework["top_character_hotspots"][0]["character_id"] == "lead" + assert framework["top_duty_hotspots"][0]["duty_type"] == "advance_plot" + + +def test_training_signal_builds_longform_250_human_review_closeout_backlog(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_250_human_closeout.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "human_closeout_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 250 human review closeout backlog。", + "life_theme": "长线验证", + "author_id": "acct_longform", + "target_total_chapters": 250, + "target_total_volumes": 5, + "target_word_count": 500000, + } + ) + version = repository.get_world_version(draft["world_version_id"]) + chapter_indices = [1, 11, 80, 100, 200, 225] + version.simulation_report_json = { + "completed_chapters": 250, + "longform_250_summary": {"target_chapters": 250}, + "chapter_evaluations": [ + { + "chapter_id": f"simulation_{draft['world_version_id']}_{chapter_index}", + "world_version_id": draft["world_version_id"], + "session_id": f"simulation:{draft['world_id']}", + "decision": {"decision": "pass", "reason": "benchmark"}, + "issues": [], + "scores": { + "readability": 0.9, + "scene_density": 0.9, + "character_fidelity": 0.9, + "causal_continuity": 0.9, + "pacing": 0.9, + "choice_distinctness": 0.9, + "hook_quality": 0.9, + "monetize_ready": 0.9, + "overall_score": 0.9, + }, + "hard_validator_results": {}, + "summary": f"chapter {chapter_index}", + "created_at": "2026-04-06T00:00:00+00:00", + } + for chapter_index in chapter_indices + ], + } + repository.save_world_version(version, publish=False) + training_signal = TrainingSignalService(repository) + + summary = training_signal.longform_250_human_review_closeout(world_id=draft["world_id"]) + + assert summary["planned_target_count"] == 6 + assert summary["human_reviewed_target_count"] == 0 + assert summary["human_closeout_ready"] is False + assert summary["human_closeout_status"] == "watch" + assert len(summary["backlog"]) == 6 + + +def test_training_signal_builds_longform_500_human_review_closeout_backlog(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_500_human_closeout.db")) + authoring = AuthoringService(repository) + draft = authoring.create_draft_from_brief( + { + "genre_preset": "synthetic", + "world_title": "human_closeout_world_500", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 500 human review closeout backlog。", + "life_theme": "长线验证", + "author_id": "acct_longform", + "target_total_chapters": 500, + "target_total_volumes": 10, + "target_word_count": 1000000, + } + ) + version = repository.get_world_version(draft["world_version_id"]) + chapter_indices = [1, 21, 220, 260, 460, 480] + version.simulation_report_json = { + "completed_chapters": 500, + "longform_500_summary": {"target_chapters": 500}, + "chapter_evaluations": [ + { + "chapter_id": f"simulation_{draft['world_version_id']}_{chapter_index}", + "world_version_id": draft["world_version_id"], + "session_id": f"simulation:{draft['world_id']}", + "decision": {"decision": "pass", "reason": "benchmark"}, + "issues": [], + "scores": { + "readability": 0.9, + "scene_density": 0.9, + "character_fidelity": 0.9, + "causal_continuity": 0.9, + "pacing": 0.9, + "choice_distinctness": 0.9, + "hook_quality": 0.9, + "monetize_ready": 0.9, + "overall_score": 0.9, + }, + "hard_validator_results": {}, + "summary": f"chapter {chapter_index}", + "created_at": "2026-04-06T00:00:00+00:00", + } + for chapter_index in chapter_indices + ], + } + repository.save_world_version(version, publish=False) + training_signal = TrainingSignalService(repository) + + summary = training_signal.longform_500_human_review_closeout(world_id=draft["world_id"]) + + assert summary["planned_target_count"] == 6 + assert summary["human_reviewed_target_count"] == 0 + assert summary["human_closeout_ready"] is False + assert summary["human_closeout_status"] == "watch" + assert summary["ending_window_label"] == "460-500" + assert summary["ending_window_target_count"] == 2 + assert summary["ending_window_human_reviewed_count"] == 0 + assert summary["ending_window_human_closeout_ready"] is False + assert len(summary["backlog"]) == 6 + + +def test_runtime_fallback_uses_more_volumes_for_500_targets(): + structure = _resolve_longform_structure( + worldpack_payload={"world_id": "legacy_runtime_world", "title": "Legacy Runtime World", "metadata": {}}, + runtime_world_title="Legacy Runtime World", + max_chapters=500, + ) + + assert structure["series_plan"]["total_chapter_target"] == 500 + assert structure["series_plan"]["total_volume_target"] >= 8 + + +def test_runtime_fallback_uses_more_volumes_for_1000_targets(): + structure = _resolve_longform_structure( + worldpack_payload={"world_id": "legacy_runtime_world", "title": "Legacy Runtime World", "metadata": {}}, + runtime_world_title="Legacy Runtime World", + max_chapters=1000, + ) + + assert structure["series_plan"]["total_chapter_target"] == 1000 + assert structure["series_plan"]["total_volume_target"] >= 16 + + +def test_longform_100_gate_surfaces_mid_arc_and_stop_reason_evidence(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "longform_gate_evidence.db")) + + def simulation_runner(_world_id: str, world_version_id: str): + return { + "world_version_id": world_version_id, + "world_id": "synthetic_min_pack", + "completed_chapters": 8, + "chapter_budget": 100, + "completion_ratio": 0.08, + "stop_reason": "no_legal_routes", + "min_end_turn_target": 90, + "chapter_evaluations": [], + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "top_issue_categories": [], + }, + "longform_summary": { + "character_drift_rate": 0.0, + "promise_unresolved_rate": 0.2, + "arc_task_repeat_rate": 0.3, + "q09_incidence_rate": 0.0, + "premature_ending_trigger_rate": 0.0, + "volume_climax_spacing_error": 0.0, + }, + } + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="synthetic_min_pack", + simulation_runner=simulation_runner, + benchmark_mode="longform_100", + max_chapters=100, + ) + + gate = report["worlds"][0]["longform_gate"] + assert gate["passed"] is False + assert "stop_reason" in gate["failed_checks"] + assert "mid_arc_window_reached" in gate["failed_checks"] + assert report["worlds"][0]["q09_incidence_rate"] == 0.0 diff --git a/tests/test_providers_and_critics.py b/tests/test_providers_and_critics.py index 5b2c846..79c5988 100644 --- a/tests/test_providers_and_critics.py +++ b/tests/test_providers_and_critics.py @@ -5,6 +5,7 @@ from src.narrativeos.models import EventAtom, NarrativeState, PromiseLedgerEntry, WorldBible from src.narrativeos.providers import InlineJSONLLMBackend, LLMCandidateProvider, StaticCandidateProvider from src.narrativeos.search import evaluate_candidates +from src.narrativeos.worldpacks.registry import FileSystemWorldRegistry def test_static_candidate_provider_meets_phase2_counts(demo_world, demo_state, demo_events): @@ -17,6 +18,16 @@ def test_static_candidate_provider_meets_phase2_counts(demo_world, demo_state, d assert batch.illegal_candidate_reasons +def test_static_candidate_provider_exposes_cache_hit_on_repeat_generation(demo_world, demo_state, demo_events): + provider = StaticCandidateProvider(demo_events) + first = provider.generate(demo_state, demo_world, min_candidates=6, max_candidates=10) + second = provider.generate(demo_state, demo_world, min_candidates=6, max_candidates=10) + + assert first.debug["cache_hit"] is False + assert second.debug["cache_hit"] is True + assert second.debug["cache_key"] + + def test_llm_candidate_provider_validates_payload_and_backfills(demo_world, demo_state, demo_events): llm_payload = { "candidate_events": [ @@ -57,6 +68,278 @@ def test_static_candidate_provider_synthesizes_continuation_candidates_when_pool assert batch.legal_candidates +def test_static_candidate_provider_enables_continuations_for_longform_even_below_short_route_threshold( + demo_world, demo_state, demo_events +): + longform_state = NarrativeState.from_dict(demo_state.to_dict()) + longform_state.visited_event_ids = [event.event_id for event in demo_events] + longform_state.chapter_index = 2 + longform_state.story_phase = "early_rising" + longform_state.min_end_turn = 6 + longform_state.metadata["longform_plan_enabled"] = True + provider = StaticCandidateProvider(demo_events) + + batch = provider.generate(longform_state, demo_world, min_candidates=4, max_candidates=6) + + assert batch.debug["continuation_mode"] == "longform" + assert batch.debug["continuation_candidate_count"] > 0 + assert batch.legal_candidates + + +def test_jade_accept_exam_nomination_prefers_runtime_continuation_blueprints(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("jade_court_exam@1.0.0") + provider = StaticCandidateProvider(runtime.event_atoms) + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.visited_event_ids = [event.event_id for event in runtime.event_atoms] + state.chapter_index = 4 + state.story_phase = "midpoint" + state.min_end_turn = 12 + + batch = provider.generate(state, runtime.world_record.world, min_candidates=4, max_candidates=6) + + continuation_candidates = [ + event + for event in batch.raw_candidates + if event.metadata.get("base_event_id") == "accept_exam_nomination" + ] + assert continuation_candidates + assert any( + "荣老太君顺着体面把最难认的那句逼到余澄面前" in event.title + or "徐师在书房里把那句不能再装糊涂的话留给余澄自己来认" in event.title + for event in continuation_candidates + ) + + +def test_urban_synthesized_runtime_events_expose_continuation_blueprints_and_promise_closure(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("urban_mystery_lotus_lane@0.1.0") + base_event = next( + event + for event in runtime.event_atoms + if (event.metadata or {}).get("scene_blueprint_id") == "alley_meet" + ) + assert (base_event.metadata or {}).get("continuation_blueprints") + + provider = StaticCandidateProvider(runtime.event_atoms) + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.visited_event_ids = [event.event_id for event in runtime.event_atoms] + state.chapter_index = 20 + state.turn_index = 20 + state.story_phase = "aftermath" + state.min_end_turn = 12 + state.current_chapter_task = { + "duty_type": "pace_breath", + "promise_actions": ["maintain_continuity"], + } + state.open_promises = [ + PromiseLedgerEntry( + promise_id="alley_meet__promise", + description="旧巷那一夜迟早要被真正说清。", + opened_at_turn=1, + due_by_turn=3, + holders=["jiang_yi", "zhou_lan"], + fulfillment_modes=["truth"], + status="open", + stakes="trust", + tags=["false_peace"], + ), + PromiseLedgerEntry( + promise_id="truth_request__promise", + description="追问不能永远停在半句真话上。", + opened_at_turn=2, + due_by_turn=4, + holders=["jiang_yi", "zhou_lan"], + fulfillment_modes=["truth"], + status="open", + stakes="trust", + tags=["truth_trial"], + ), + PromiseLedgerEntry( + promise_id="rooftop_confession__promise", + description="天台那次真话不能一直停在风口。", + opened_at_turn=5, + due_by_turn=7, + holders=["jiang_yi", "zhou_lan"], + fulfillment_modes=["truth"], + status="open", + stakes="trust", + tags=["confession_window"], + ), + PromiseLedgerEntry( + promise_id="lotus_file_ripening__promise", + description="旧案翻出的代价迟早要结算。", + opened_at_turn=6, + due_by_turn=8, + holders=["jiang_yi", "zhou_lan"], + fulfillment_modes=["repair"], + status="open", + stakes="medium", + tags=["karma_ripening"], + ), + PromiseLedgerEntry( + promise_id="archive_mask_crack__promise", + description="档案室里露出的裂口不能再被解释缝回去。", + opened_at_turn=7, + due_by_turn=9, + holders=["jiang_yi", "zhou_lan"], + fulfillment_modes=["truth"], + status="open", + stakes="medium", + tags=["mask_crack"], + ), + ] + + batch = provider.generate(state, runtime.world_record.world, min_candidates=4, max_candidates=6) + continuation_candidates = [event for event in batch.raw_candidates if event.metadata.get("continuation_variant")] + assert continuation_candidates + assert any(event.promises_close for event in continuation_candidates) + assert any( + "那次旧巷相遇真正没说完的话终于被逼到风口" in event.title + or "天台那次停住的后半句终于被江屹自己补完" in event.title + or "因果回潮之后,两个人终于得到一次不靠试探的缓口气" in event.title + for event in continuation_candidates + ) + + +def test_xianxia_synthesized_runtime_events_expose_continuation_blueprints(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("xianxia_forgotten_vow@0.1.0") + base_event = next( + event + for event in runtime.event_atoms + if (event.metadata or {}).get("scene_blueprint_id") == "vow_trial" + ) + assert (base_event.metadata or {}).get("continuation_blueprints") + + provider = StaticCandidateProvider(runtime.event_atoms) + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.visited_event_ids = [event.event_id for event in runtime.event_atoms] + state.chapter_index = 18 + state.turn_index = 18 + state.story_phase = "climax" + state.min_end_turn = 12 + state.current_chapter_task = { + "duty_type": "deliver_climax", + "promise_actions": ["close_arc_loop", "maintain_continuity"], + } + state.open_promises = [ + PromiseLedgerEntry( + promise_id="vow_trial__promise", + description="山门前那句最重的话迟早要被真正认下。", + opened_at_turn=2, + due_by_turn=5, + holders=["shen_zhao", "ye_qingzhu"], + fulfillment_modes=["truth"], + status="open", + stakes="destiny", + tags=["temptation"], + ) + ] + + batch = provider.generate(state, runtime.world_record.world, min_candidates=4, max_candidates=6) + continuation_candidates = [event for event in batch.raw_candidates if event.metadata.get("continuation_variant")] + assert continuation_candidates + assert any(event.promises_close for event in continuation_candidates) + assert any( + "照骨灯前那句一直绕着走的旧誓终于被逼到明处" in event.title + or "照骨灯照出来的那句真话终于换成了正面逼问" in event.title + or "山门前那句若天命与旧誓撞上先舍哪一个终于被追到最重" in event.title + or "旧誓动摇以后,终于出现了一个不能再拿大道遮羞的窗口" in event.title + for event in continuation_candidates + ) + + +def test_synthetic_pack_synthesized_runtime_events_expose_continuation_blueprints(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("synthetic_min_pack@0.1.0") + base_event = next( + event + for event in runtime.event_atoms + if (event.metadata or {}).get("scene_blueprint_id") == "synthetic_setup" + ) + assert (base_event.metadata or {}).get("continuation_blueprints") + + provider = StaticCandidateProvider(runtime.event_atoms) + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.visited_event_ids = [event.event_id for event in runtime.event_atoms] + state.chapter_index = 14 + state.turn_index = 14 + state.story_phase = "climax" + state.min_end_turn = 12 + state.current_chapter_task = { + "duty_type": "resolve_promise", + "promise_actions": ["advance_payoff", "maintain_continuity"], + } + state.open_promises = [ + PromiseLedgerEntry( + promise_id="synthetic_setup__promise", + description="最开始那句没认完的话迟早要被说清。", + opened_at_turn=1, + due_by_turn=3, + holders=["lead_a", "lead_b"], + fulfillment_modes=["truth"], + status="open", + stakes="trust", + tags=["setup"], + ) + ] + + batch = provider.generate(state, runtime.world_record.world, min_candidates=4, max_candidates=6) + continuation_candidates = [event for event in batch.raw_candidates if event.metadata.get("continuation_variant")] + assert continuation_candidates + assert any(event.promises_close for event in continuation_candidates) + assert any( + "正面碰撞" in event.title + or "后果回潮" in event.title + or "终于被逼到最前面" in event.title + or "终于开始有人认回去" in event.title + for event in continuation_candidates + ) + + +def test_jade_court_romance_runtime_events_expose_continuation_blueprints(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("jade_court_romance@1.0.0") + base_event = next(event for event in runtime.event_atoms if event.event_id == "accept_exam_nomination") + assert (base_event.metadata or {}).get("continuation_blueprints") + + provider = StaticCandidateProvider(runtime.event_atoms) + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + state.visited_event_ids = [event.event_id for event in runtime.event_atoms] + state.chapter_index = 18 + state.turn_index = 18 + state.story_phase = "climax" + state.min_end_turn = 12 + state.current_chapter_task = { + "duty_type": "advance_relationship", + "promise_actions": ["maintain_continuity"], + } + state.open_promises = [ + PromiseLedgerEntry( + promise_id="must_sit_first_exam", + description="春闱之命必须被真正兑现或公开改写。", + opened_at_turn=1, + due_by_turn=3, + holders=["yu_cheng", "lady_rong"], + fulfillment_modes=["truth"], + status="open", + stakes="family_reputation_and_selfhood", + tags=["exam"], + ) + ] + + batch = provider.generate(state, runtime.world_record.world, min_candidates=4, max_candidates=6) + continuation_candidates = [event for event in batch.raw_candidates if event.metadata.get("continuation_variant")] + assert continuation_candidates + assert any(event.promises_close for event in continuation_candidates) + assert any( + "春闱之命压下来以后,余澄第一次被逼着把那层真心说到更前面" in event.title + or "那次试探没有白过去,终于出现了一个能把真心说全的窗口" in event.title + for event in continuation_candidates + ) + + def test_critics_surface_revisions_and_rejections(demo_world, demo_state): revised_state = NarrativeState.from_dict(demo_state.to_dict()) revised_state.recent_scene_functions = ["temptation"] diff --git a/tests/test_quality_config.py b/tests/test_quality_config.py new file mode 100644 index 0000000..4e4179a --- /dev/null +++ b/tests/test_quality_config.py @@ -0,0 +1,152 @@ +from pathlib import Path + +import yaml + +from src.narrativeos.quality.config import ( + QualityConfigError, + QualityConfigPaths, + load_quality_config_bundle, +) + + +def test_quality_config_bundle_loads_default_configs(): + bundle = load_quality_config_bundle() + + assert bundle["scenarios"]["config_version"] == "quality_scenarios_v1" + assert bundle["risk_tiers"]["config_version"] == "quality_risk_tiers_v1" + assert bundle["rules"]["config_version"] == "quality_rules_v1" + assert bundle["content_rubrics"]["config_version"] == "content_rubrics_v1" + assert bundle["review_policies"]["config_version"] == "quality_review_policies_v1" + assert bundle["review_policies"]["policy_map"]["qp_reader_continue_v1"].scenario_id == "reader_continue" + + +def test_quality_config_bundle_rejects_missing_required_keys(tmp_path: Path): + config_dir = tmp_path / "quality" + config_dir.mkdir() + (config_dir / "scenarios.yaml").write_text( + "config_version: x\nscenarios:\n - scenario_id: reader_continue\n surface: reader\n description: desc\n default_risk_tier: L1\n quality_policy_id: missing_policy\n", + encoding="utf-8", + ) + (config_dir / "risk_tiers.yaml").write_text( + "config_version: x\nrisk_tiers:\n - risk_tier: L1\n label: low\n description: desc\n requires_human_review: false\n blocks_on_veto_only: true\n", + encoding="utf-8", + ) + (config_dir / "rules.yaml").write_text( + "config_version: x\nrules:\n - rule_id: rule_1\n rule_type: validator\n severity: high\n blocking: true\n config_ref: config\n reason_code: reason\n", + encoding="utf-8", + ) + (config_dir / "content_rubrics.yaml").write_text( + "config_version: x\nrubrics: {default: {rubric_version: v1, overall_scale: {min: 1, max: 5}, dimensions: {correctness: {min: 1, max: 5}}, veto_reason_codes: []}}\n", + encoding="utf-8", + ) + (config_dir / "review_policies.yaml").write_text("config_version: x\npolicies: []\n", encoding="utf-8") + + try: + load_quality_config_bundle(QualityConfigPaths(config_dir=config_dir)) + except QualityConfigError as exc: + assert "quality_scenario_missing_policy" in str(exc) + else: + raise AssertionError("invalid config bundle should raise") + + +def test_quality_config_bundle_rejects_invalid_risk_tier(tmp_path: Path): + config_dir = tmp_path / "quality" + config_dir.mkdir() + (config_dir / "scenarios.yaml").write_text( + yaml.safe_dump( + { + "config_version": "test", + "scenarios": [ + { + "scenario_id": "reader_continue", + "surface": "reader", + "description": "desc", + "default_risk_tier": "L9", + "quality_policy_id": "qp_reader_continue_v1", + } + ], + }, + sort_keys=False, + ), + encoding="utf-8", + ) + (config_dir / "risk_tiers.yaml").write_text( + yaml.safe_dump( + { + "config_version": "test", + "risk_tiers": [ + { + "risk_tier": "L1", + "label": "low", + "description": "desc", + "requires_human_review": False, + "blocks_on_veto_only": True, + } + ], + }, + sort_keys=False, + ), + encoding="utf-8", + ) + (config_dir / "rules.yaml").write_text( + yaml.safe_dump( + { + "config_version": "test", + "rules": [ + { + "rule_id": "rule_1", + "rule_type": "validator", + "severity": "high", + "blocking": True, + "config_ref": "config", + "reason_code": "reason", + } + ], + }, + sort_keys=False, + ), + encoding="utf-8", + ) + (config_dir / "content_rubrics.yaml").write_text( + yaml.safe_dump( + { + "config_version": "test", + "rubrics": { + "default": { + "rubric_version": "v1", + "overall_scale": {"min": 1, "max": 5}, + "dimensions": {"correctness": {"min": 1, "max": 5}}, + "veto_reason_codes": [], + } + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + (config_dir / "review_policies.yaml").write_text( + yaml.safe_dump( + { + "config_version": "test", + "policies": [ + { + "policy_id": "qp_reader_continue_v1", + "version": "v1", + "scenario_id": "reader_continue", + "risk_tier": "L1", + "rule_ids": ["rule_1"], + "mode": "observe", + } + ], + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + try: + load_quality_config_bundle(QualityConfigPaths(config_dir=config_dir)) + except QualityConfigError as exc: + assert "quality_scenario_risk_tier_invalid" in str(exc) + else: + raise AssertionError("invalid scenario risk tier should raise") diff --git a/tests/test_quality_eval_harness.py b/tests/test_quality_eval_harness.py new file mode 100644 index 0000000..256095f --- /dev/null +++ b/tests/test_quality_eval_harness.py @@ -0,0 +1,42 @@ +from pathlib import Path +import json +import subprocess +import sys + +from src.narrativeos.schemas import validate_payload + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_quality_eval_sample_fixture_schema(): + for bucket in ["normal", "boundary", "adversarial"]: + for path in sorted((ROOT / "tests" / "fixtures" / "quality_eval" / bucket).glob("*.json")): + payload = json.loads(path.read_text(encoding="utf-8")) + validate_payload(payload, "quality_eval_sample.schema.json") + + +def test_grounding_eval_harness_outputs_artifacts(tmp_path: Path): + script = ROOT / "scripts" / "run_grounding_eval.py" + result = subprocess.run([sys.executable, str(script)], cwd=str(ROOT), capture_output=True, text=True) + assert result.returncode == 0 + runs = sorted((ROOT / "artifacts" / "quality_eval").glob("grounding_eval_*")) + assert runs + latest = runs[-1] + assert (latest / "summary.json").exists() + assert (latest / "summary.md").exists() + assert (latest / "failed_samples.json").exists() + summary = json.loads((latest / "summary.json").read_text(encoding="utf-8")) + validate_payload(summary, "quality_eval_run.schema.json") + + +def test_quality_eval_harness_outputs_failed_samples(tmp_path: Path): + script = ROOT / "scripts" / "run_quality_eval.py" + result = subprocess.run([sys.executable, str(script)], cwd=str(ROOT), capture_output=True, text=True) + assert result.returncode == 0 + runs = sorted((ROOT / "artifacts" / "quality_eval").glob("quality_eval_*")) + assert runs + latest = runs[-1] + failed = json.loads((latest / "failed_samples.json").read_text(encoding="utf-8")) + assert isinstance(failed, list) + assert (latest / "metrics.json").exists() diff --git a/tests/test_quality_models.py b/tests/test_quality_models.py new file mode 100644 index 0000000..201cbe9 --- /dev/null +++ b/tests/test_quality_models.py @@ -0,0 +1,137 @@ +from src.narrativeos.quality.models import ( + ContentQualityScore, + GuardrailDecision, + QualityFeedbackItem, + QualityEvent, + QualityPolicy, + QualityRule, + ReviewCase, +) + + +def test_quality_domain_objects_round_trip(): + policy = QualityPolicy.from_dict( + { + "policy_id": "qp_reader_continue_v1", + "version": "v1", + "scenario_id": "reader_continue", + "risk_tier": "L2", + "rule_ids": ["chapter_quality_gate"], + "mode": "observe", + "metadata": {"surface": "reader"}, + } + ) + rule = QualityRule.from_dict( + { + "rule_id": "chapter_quality_gate", + "rule_type": "evaluator", + "severity": "high", + "blocking": True, + "config_ref": "src.narrativeos.eval.service:evaluate_persisted_chapter", + "reason_code": "chapter_quality_guard_failed", + } + ) + decision = GuardrailDecision.from_dict( + { + "trace_id": "trace_1", + "status": "review_required", + "scenario_id": "reader_continue", + "risk_tier": "L2", + "rule_hits": [{"rule_id": "chapter_quality_gate"}], + "scores_ref": "score_1", + "grounding_result": {"status": "not_evaluated"}, + "review_required": True, + "review_case_id": "review_case_1", + } + ) + score = ContentQualityScore.from_dict( + { + "score_id": "score_1", + "rubric_version": "content_quality_rubric_v1", + "overall_score": 3.6, + "dimension_scores": {"readability": 4, "executability": 3}, + "veto": False, + "reason_codes": ["Q05"], + "evidence_refs": [{"kind": "evaluation_report", "ref_id": "chapter_1"}], + } + ) + review_case = ReviewCase.from_dict( + { + "case_id": "review_case_1", + "case_type": "runtime_quality", + "status": "open", + "owner_id": None, + "source_ref": {"kind": "session", "session_id": "session_1"}, + "reason_codes": ["chapter_quality_guard_failed"], + "evidence_refs": [{"kind": "quality_event", "ref_id": "event_1"}], + } + ) + event = QualityEvent.from_dict( + { + "event_id": "event_1", + "trace_id": "trace_1", + "event_type": "guardrail_decision", + "source_surface": "reader", + "source_ref": {"kind": "session", "session_id": "session_1"}, + "payload": {"status": "review_required"}, + "created_at": "2026-04-13T12:00:00+00:00", + } + ) + feedback = QualityFeedbackItem.from_dict( + { + "feedback_item_id": "feedback_1", + "trace_id": "trace_1", + "source_event_id": "analytics_1", + "feedback_type": "retry_after_quality_guard", + "signal": "retry", + "source_surface": "reader", + "account_id": "acct_1", + "world_version_id": "jade_court_exam@0.1.0", + "session_id": "session_1", + "chapter_id": "chapter_1", + "source_ref": {"kind": "session", "session_id": "session_1"}, + "payload": {"result_status": "ok"}, + "created_at": "2026-04-14T10:00:00+00:00", + } + ) + + assert QualityPolicy.from_dict(policy.to_dict()) == policy + assert QualityRule.from_dict(rule.to_dict()) == rule + assert GuardrailDecision.from_dict(decision.to_dict()) == decision + assert ContentQualityScore.from_dict(score.to_dict()) == score + assert ReviewCase.from_dict(review_case.to_dict()) == review_case + assert QualityEvent.from_dict(event.to_dict()) == event + assert QualityFeedbackItem.from_dict(feedback.to_dict()) == feedback + + +def test_quality_domain_objects_validate_enums(): + try: + QualityRule.from_dict( + { + "rule_id": "rule_1", + "rule_type": "bad_type", + "severity": "high", + "blocking": True, + "config_ref": "config", + "reason_code": "bad", + } + ) + except ValueError as exc: + assert "quality_rule_type_invalid" in str(exc) + else: + raise AssertionError("invalid rule_type should raise") + + try: + GuardrailDecision.from_dict( + { + "trace_id": "trace_1", + "status": "unexpected", + "scenario_id": "reader_continue", + "risk_tier": "L2", + "rule_hits": [], + } + ) + except ValueError as exc: + assert "guardrail_status_invalid" in str(exc) + else: + raise AssertionError("invalid guardrail status should raise") diff --git a/tests/test_quality_repository.py b/tests/test_quality_repository.py new file mode 100644 index 0000000..d3dc277 --- /dev/null +++ b/tests/test_quality_repository.py @@ -0,0 +1,106 @@ +from pathlib import Path + +from src.narrativeos.repository import SQLAlchemyRepository + + +def test_quality_repository_round_trip_for_policies_events_scores_and_cases(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "quality_repository.db")) + + policy = repository.save_quality_policy( + { + "policy_id": "qp_reader_continue_v1", + "version": "v1", + "scenario_id": "reader_continue", + "risk_tier": "L2", + "mode": "observe", + "rule_ids": ["chapter_quality_gate", "runtime_grounding_placeholder"], + "policy_payload": {"source": "tests"}, + } + ) + assert policy["policy_id"] == "qp_reader_continue_v1" + assert repository.list_quality_policies(scenario_id="reader_continue")[0]["policy_id"] == "qp_reader_continue_v1" + + event = repository.save_quality_event( + { + "event_id": "quality_event_1", + "trace_id": "trace_1", + "event_type": "guardrail_decision", + "source_surface": "reader", + "status": "review_required", + "world_version_id": "jade_court_exam@0.1.0", + "session_id": "session_1", + "source_ref": {"kind": "session", "session_id": "session_1"}, + "payload": {"status": "review_required"}, + } + ) + assert event["trace_id"] == "trace_1" + assert repository.list_quality_events(trace_id="trace_1")[0]["event_id"] == "quality_event_1" + + score = repository.save_content_quality_score( + { + "score_id": "quality_score_1", + "trace_id": "trace_1", + "source_surface": "reader", + "status": "review_required", + "world_version_id": "jade_court_exam@0.1.0", + "session_id": "session_1", + "chapter_id": "chapter_session_1_1", + "rubric_version": "content_quality_rubric_v1", + "overall_score": 3.5, + "veto": False, + "dimension_scores": {"readability": 4, "executability": 3}, + "reason_codes": ["Q05"], + "evidence_refs": [{"kind": "quality_event", "ref_id": "quality_event_1"}], + "score_payload": {"source": "tests"}, + } + ) + assert repository.get_content_quality_score("quality_score_1")["overall_score"] == 3.5 + assert repository.list_content_quality_scores(trace_id="trace_1")[0]["score_id"] == "quality_score_1" + + case = repository.save_review_case( + { + "case_id": "review_case_1", + "trace_id": "trace_1", + "case_type": "runtime_quality", + "status": "open", + "owner_id": None, + "source_surface": "reader", + "world_version_id": "jade_court_exam@0.1.0", + "session_id": "session_1", + "score_id": "quality_score_1", + "source_ref": {"kind": "session", "session_id": "session_1"}, + "reason_codes": ["chapter_quality_guard_failed"], + "evidence_refs": [{"kind": "content_quality_score", "ref_id": "quality_score_1"}], + "case_payload": {"source": "tests"}, + } + ) + assert case["case_id"] == "review_case_1" + updated = repository.update_review_case_status( + "review_case_1", + status="in_review", + owner_id="ops_web", + reason_codes=["chapter_quality_guard_failed", "quality_review_required"], + ) + assert updated["status"] == "in_review" + assert updated["owner_id"] == "ops_web" + assert repository.get_review_case("review_case_1")["status"] == "in_review" + assert repository.list_review_cases(trace_id="trace_1")[0]["case_id"] == "review_case_1" + + feedback = repository.save_quality_feedback_item( + { + "feedback_item_id": "quality_feedback_1", + "trace_id": "trace_1", + "source_event_id": "analytics_1", + "feedback_type": "retry_after_quality_guard", + "signal": "retry", + "source_surface": "reader", + "account_id": "acct_1", + "world_version_id": "jade_court_exam@0.1.0", + "session_id": "session_1", + "chapter_id": "chapter_session_1_1", + "source_ref": {"kind": "session", "session_id": "session_1"}, + "payload": {"result_status": "ok"}, + } + ) + assert feedback["feedback_item_id"] == "quality_feedback_1" + assert repository.list_quality_feedback_items(trace_id="trace_1")[0]["signal"] == "retry" diff --git a/tests/test_reader_storybook_title_homogenization_trend.py b/tests/test_reader_storybook_title_homogenization_trend.py new file mode 100644 index 0000000..90cc089 --- /dev/null +++ b/tests/test_reader_storybook_title_homogenization_trend.py @@ -0,0 +1,163 @@ +from src.narrativeos.services.reader_storybook_title_homogenization import ( + append_reader_storybook_title_homogenization_history_entry, + build_reader_storybook_title_homogenization_trend, + promoted_reader_storybook_title_homogenization_pairs_for_world, +) + + +def _entry(*, generated_at: str, world_ids, warning: bool) -> dict: + comparison = { + "non_jade_world_id": "urban_mystery_lotus_lane", + "jade_world_id": "jade_court_romance", + "title_similarity": 1.0, + "quote_similarity": 0.013, + "passes_min_difference": True, + } + warnings = [] + if warning: + warnings.append( + { + "non_jade_world_id": "urban_mystery_lotus_lane", + "jade_world_id": "jade_court_romance", + "title_similarity": 1.0, + "quote_similarity": 0.013, + "warning_kind": "title_homogenization_non_blocking", + "message": "sampled titles are highly similar across packs, but quote-token overlap remains below the blocking threshold.", + } + ) + return { + "generated_at": generated_at, + "world_ids": list(world_ids), + "cross_pack_distinctness": [comparison], + "title_homogenization_warnings": warnings, + } + + +def _pair(trend: dict) -> dict: + return next( + item + for item in trend["pair_trends"] + if item["non_jade_world_id"] == "urban_mystery_lotus_lane" + and item["jade_world_id"] == "jade_court_romance" + ) + + +def test_reader_storybook_title_homogenization_trend_promotes_after_three_consecutive_runs(): + history = {} + for generated_at in [ + "2026-04-17T10:00:00+00:00", + "2026-04-18T10:00:00+00:00", + "2026-04-19T10:00:00+00:00", + ]: + history = append_reader_storybook_title_homogenization_history_entry( + history, + _entry( + generated_at=generated_at, + world_ids=["urban_mystery_lotus_lane", "jade_court_romance"], + warning=True, + ), + ) + + trend = build_reader_storybook_title_homogenization_trend(history) + pair = _pair(trend) + + assert pair["consecutive_warning_count"] == 3 + assert pair["trend_status"] == "promoted" + assert pair["promoted_to_release_review"] is True + assert trend["promoted_pair_count"] == 1 + assert promoted_reader_storybook_title_homogenization_pairs_for_world( + trend, + world_id="urban_mystery_lotus_lane", + ) + + +def test_reader_storybook_title_homogenization_trend_does_not_promote_before_threshold(): + history = {} + for generated_at in [ + "2026-04-17T10:00:00+00:00", + "2026-04-18T10:00:00+00:00", + ]: + history = append_reader_storybook_title_homogenization_history_entry( + history, + _entry( + generated_at=generated_at, + world_ids=["urban_mystery_lotus_lane", "jade_court_romance"], + warning=True, + ), + ) + + trend = build_reader_storybook_title_homogenization_trend(history) + pair = _pair(trend) + + assert pair["consecutive_warning_count"] == 2 + assert pair["trend_status"] == "watch" + assert pair["promoted_to_release_review"] is False + assert trend["promoted_pair_count"] == 0 + + +def test_reader_storybook_title_homogenization_trend_ignores_runs_that_do_not_cover_both_worlds(): + history = {} + for entry in [ + _entry( + generated_at="2026-04-17T10:00:00+00:00", + world_ids=["urban_mystery_lotus_lane", "jade_court_romance"], + warning=True, + ), + _entry( + generated_at="2026-04-18T10:00:00+00:00", + world_ids=["jade_court_exam", "jade_court_romance"], + warning=False, + ), + _entry( + generated_at="2026-04-19T10:00:00+00:00", + world_ids=["urban_mystery_lotus_lane", "jade_court_romance"], + warning=True, + ), + _entry( + generated_at="2026-04-20T10:00:00+00:00", + world_ids=["urban_mystery_lotus_lane", "jade_court_romance"], + warning=True, + ), + ]: + history = append_reader_storybook_title_homogenization_history_entry(history, entry) + + trend = build_reader_storybook_title_homogenization_trend(history) + pair = _pair(trend) + + assert pair["eligible_run_count"] == 3 + assert pair["consecutive_warning_count"] == 3 + assert pair["promoted_to_release_review"] is True + + +def test_reader_storybook_title_homogenization_trend_resets_after_clean_eligible_run(): + history = {} + for entry in [ + _entry( + generated_at="2026-04-17T10:00:00+00:00", + world_ids=["urban_mystery_lotus_lane", "jade_court_romance"], + warning=True, + ), + _entry( + generated_at="2026-04-18T10:00:00+00:00", + world_ids=["urban_mystery_lotus_lane", "jade_court_romance"], + warning=True, + ), + _entry( + generated_at="2026-04-19T10:00:00+00:00", + world_ids=["urban_mystery_lotus_lane", "jade_court_romance"], + warning=False, + ), + _entry( + generated_at="2026-04-20T10:00:00+00:00", + world_ids=["urban_mystery_lotus_lane", "jade_court_romance"], + warning=True, + ), + ]: + history = append_reader_storybook_title_homogenization_history_entry(history, entry) + + trend = build_reader_storybook_title_homogenization_trend(history) + pair = _pair(trend) + + assert pair["consecutive_warning_count"] == 1 + assert pair["trend_status"] == "watch" + assert pair["promoted_to_release_review"] is False diff --git a/tests/test_rendering.py b/tests/test_rendering.py index d839e71..f62e564 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -1,6 +1,7 @@ from src.narrativeos.memory import apply_event from src.narrativeos.providers import InlineJSONLLMBackend from src.narrativeos.rendering import LLMRenderer, TemplateRenderer +from src.narrativeos.models import ChapterPlan, SceneBeat, SceneIntent, SceneRenderSpec def test_template_renderer_outputs_three_layers(demo_world, demo_state, demo_events): @@ -55,3 +56,121 @@ def test_llm_renderer_falls_back_to_template(demo_world, demo_state, demo_events assert rendered.concise_summary assert rendered.debug["renderer"] == "llm_fallback_template" + + +class SequenceLLMBackend: + provider_id = "sequence" + + def __init__(self, payloads): + self.payloads = list(payloads) + self.prompts = [] + self.last_route_debug = {} + + def generate_json(self, *, system_prompt: str, user_prompt: str): + self.prompts.append(user_prompt) + self.last_route_debug = { + "provider": self.provider_id, + "selected_provider": self.provider_id, + "attempt_count": len(self.prompts), + } + return self.payloads.pop(0) + + +def _render_scene_inputs(demo_state, demo_events): + event = {event.event_id: event for event in demo_events}["accept_exam_nomination"] + next_state = apply_event(demo_state, event) + beat = SceneBeat( + beat_index=1, + event=event, + beat_label=event.title, + dramatic_job=event.scene_function, + tension_after=next_state.tension, + ) + chapter_plan = ChapterPlan( + chapter_index=next_state.chapter_index, + story_phase=next_state.story_phase, + scene_intent=SceneIntent( + intent_id=event.scene_function, + label=event.title, + description=event.summary, + preferred_scene_functions=[event.scene_function], + preferred_tags=list(event.tags), + ), + beat_target=1, + beat_count=1, + ending_ready=False, + selected_event_ids=[event.event_id], + ) + render_spec = SceneRenderSpec( + prose_mode="novel_lush", + viewpoint_character=event.actors[0], + target_word_count=120, + min_target_word_count=80, + max_target_word_count=260, + dialogue_density=0.35, + sensory_motifs=list(event.tags[:2]), + emotional_pivot=event.scene_function, + ending_cadence="lingering", + must_include_beats=[event.title], + ) + return event, next_state, chapter_plan, [beat], render_spec + + +def _scene_payload(prose: str): + return { + "concise_summary": "余澄接下春闱之命,厅中责任继续收紧。", + "interactive_scene": "花厅里,荣老太君把春闱之命压到余澄面前。", + "premium_prose": prose, + "story_title": "第 1 章 · 花厅里的静处起波", + "chapter_summary": "花厅里的门影把表面平静推向余澄与荣老太君。", + } + + +def test_llm_renderer_retries_length_gate_before_fallback(demo_world, demo_state, demo_events): + _, next_state, chapter_plan, scene_beats, render_spec = _render_scene_inputs(demo_state, demo_events) + good_prose = ( + "花厅里灯影贴着青砖慢慢移开,余澄按住袖口,听见荣老太君的茶盖轻轻一响。" + "“孙儿明白。”他说。荣老太君没有立刻答话,只把拐杖往地上一点。" + "“明白就好。”那一声落下去,满厅的人都低了头,他却觉得门外的风更冷," + "像把还没说出口的话推回胸口,也把春闱之后的代价推到眼前。" + ) + backend = SequenceLLMBackend([ + _scene_payload("太短。"), + _scene_payload(good_prose), + ]) + renderer = LLMRenderer(backend, TemplateRenderer()) + + rendered = renderer.render_scene(demo_world, demo_state, next_state, chapter_plan, scene_beats, render_spec) + + assert rendered.debug["renderer"] == "llm" + assert rendered.debug["renderer_attempt_count"] == 2 + assert rendered.debug["llm_payload_gate"]["length_gate"]["ok"] is True + assert len(backend.prompts) == 2 + assert "previous premium_prose failed the length gate" in backend.prompts[1] + + +def test_llm_renderer_falls_back_on_malformed_scene_json(demo_world, demo_state, demo_events): + _, next_state, chapter_plan, scene_beats, render_spec = _render_scene_inputs(demo_state, demo_events) + renderer = LLMRenderer(InlineJSONLLMBackend("{bad json"), TemplateRenderer()) + + rendered = renderer.render_scene(demo_world, demo_state, next_state, chapter_plan, scene_beats, render_spec) + + assert rendered.debug["renderer"] == "llm_fallback_template" + assert rendered.debug["renderer_fallback_reason"] == "llm_backend_error" + + +def test_llm_renderer_falls_back_on_event_actor_boundary_violation(demo_world, demo_state, demo_events): + _, next_state, chapter_plan, scene_beats, render_spec = _render_scene_inputs(demo_state, demo_events) + bad_prose = ( + "花厅里灯影压着青砖,余澄听见荣老太君的拐杖轻轻一点。“孙儿明白。”" + "林绾却从侧门后退了一步,像把没有轮到她承受的秘密也带进了这场话。" + "荣老太君看着余澄,茶盏沿着案角停住,门外的风把春闱两个字吹得更重。" + ) + renderer = LLMRenderer(InlineJSONLLMBackend(_scene_payload(bad_prose)), TemplateRenderer()) + + rendered = renderer.render_scene(demo_world, demo_state, next_state, chapter_plan, scene_beats, render_spec) + + assert rendered.debug["renderer"] == "llm_fallback_template" + assert rendered.debug["renderer_fallback_reason"] == "llm_grounding_gate_failed" + issues = rendered.debug["llm_payload_gate"]["grounding_issues"] + assert any(issue["issue"] == "event_actor_boundary_violation" for issue in issues) diff --git a/tests/test_scoring.py b/tests/test_scoring.py index 7409d35..66211a7 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -1,4 +1,7 @@ -from src.narrativeos.models import SearchWeights +from copy import deepcopy + +from src.narrativeos.core.emotion_actions import compose_emotion_action +from src.narrativeos.models import EventAtom, NarrativeState, SceneBeat, WorldBible, SearchWeights from src.narrativeos.scoring import score_event @@ -43,3 +46,114 @@ def test_creator_control_weights_can_shift_route_preference(demo_world, demo_sta ) assert romance.total_score > exam.total_score + + +def test_q06_scoring_uses_character_card_and_duty_alignment(demo_world, demo_state, demo_events): + state = NarrativeState.from_dict(demo_state.to_dict()) + state.player_intent = {"romance": 0.85, "selfhood": 0.75, "honesty": 0.65} + state.current_chapter_task = { + "chapter_task_id": "task_relationship", + "duty_type": "advance_relationship", + "objective": "推进角色关系并逼近真话。", + } + state.character_memory_runtime = { + "yu_cheng": { + "structured_memory": { + "goals": ["remain_true_to_lin_wan", "seek_selfhood"], + "promises": ["宁可自己背负,也不让她替我受伤"], + "scars": ["永远要证明自己才值得被留下"], + "taboos": ["空洞功名"], + } + }, + "lin_wan": { + "structured_memory": { + "goals": ["protect_truth", "protect_yu_cheng"], + "promises": ["若他不肯说真话,我也不替他圆谎"], + "scars": ["先相信的人总是先受伤"], + "taboos": ["含混试探"], + } + }, + } + world = WorldBible.from_dict(deepcopy(demo_world.to_dict())) + world.creator_controls.theme_targets = ["love", "honesty", "selfhood"] + + events = {event.event_id: event for event in demo_events} + romance = score_event(state, events["secret_meet_lin_wan"], world=world) + exam = score_event(state, events["accept_exam_nomination"], world=world) + + assert romance.total_score > exam.total_score + assert romance.components["character_card_alignment"] > exam.components["character_card_alignment"] + assert romance.components["duty_alignment"] > exam.components["duty_alignment"] + + +def test_emotion_action_defaults_shift_with_duty_when_policy_missing(demo_world, demo_state, demo_events): + world = WorldBible.from_dict(deepcopy(demo_world.to_dict())) + world.capability_assets = {} + world.creator_controls.metadata = {} + state = NarrativeState.from_dict(demo_state.to_dict()) + beat = SceneBeat( + beat_index=1, + event=next(event for event in demo_events if event.event_id == "secret_meet_lin_wan"), + beat_label="测试 beat", + dramatic_job="pressure", + tension_after=state.tension, + ) + + state.current_chapter_task = {"duty_type": "advance_relationship"} + relationship_text = compose_emotion_action(world, state, beat, repeated=False) + state.current_chapter_task = {"duty_type": "resolve_promise"} + promise_text = compose_emotion_action(world, state, beat, repeated=False) + + assert relationship_text != promise_text + assert any(token in relationship_text for token in ["靠近", "试探", "真心"]) + assert any(token in promise_text for token in ["旧账", "真话", "认下"]) + + +def test_longform_scoring_penalizes_terminal_and_repeated_clusters_before_late_window(demo_world, demo_state, demo_events): + state = NarrativeState.from_dict(demo_state.to_dict()) + state.current_chapter_task = { + "chapter_task_id": "task_climax", + "duty_type": "deliver_climax", + "objective": "推进终局前压力,但不能过早收束。", + "quality_contract": {"continuation_pressure_required": True}, + } + state.recent_scene_functions = ["vow_payment", "vow_payment", "truth_trial"] + state.metadata["recent_duty_types"] = ["deliver_climax", "deliver_climax"] + state.metadata["longform_progression"] = { + "series_target_chapters": 200, + "series_chapter_index": 120, + } + state.metadata["replan_debt"] = { + "status": "active", + "issue_codes": ["Q09"], + "active_until_chapter": 130, + "intensity": 2, + } + terminal_event = EventAtom.from_dict( + { + **deepcopy(next(event for event in demo_events if event.event_id == "accept_exam_nomination").to_dict()), + "event_id": "terminal_test_event", + "scene_function": "vow_payment", + "metadata": {"terminal": True, "ending_gate": {"min_turn": 180}}, + "tags": ["truth", "destiny", "climax"], + } + ) + recovery_event = EventAtom.from_dict( + { + **deepcopy(next(event for event in demo_events if event.event_id == "secret_meet_lin_wan").to_dict()), + "event_id": "recovery_test_event", + "scene_function": "debt_exchange", + "metadata": {"terminal": False}, + "tags": ["truth", "love", "reputation", "loyalty"], + } + ) + + terminal = score_event(state, terminal_event, world=demo_world) + recovery = score_event(state, recovery_event, world=demo_world) + + assert recovery.total_score > terminal.total_score + assert terminal.components["terminal_before_late_penalty"] > 0.0 + assert terminal.components["duty_cluster_penalty"] > 0.0 + assert terminal.components["replan_debt_penalty"] > 0.0 + assert recovery.components["continuation_pressure_bonus"] > 0.0 + assert recovery.components["relationship_debt_bonus"] > 0.0 diff --git a/tests/test_sensory_grounding.py b/tests/test_sensory_grounding.py index f8a38d1..b7fbb97 100644 --- a/tests/test_sensory_grounding.py +++ b/tests/test_sensory_grounding.py @@ -1,4 +1,5 @@ from src.narrativeos.core.sensory_grounding import scene_atmosphere, scene_detail +from src.narrativeos.prose_linter import lint_prose from src.narrativeos.worldpacks.registry import FileSystemWorldRegistry @@ -10,3 +11,95 @@ def test_sensory_grounding_varies_across_packs(): xianxia_beat = type("Beat", (), {"event": xianxia.event_atoms[0], "dramatic_job": "entry"})() assert scene_atmosphere(jade.world_record.world, jade_beat) != scene_atmosphere(xianxia.world_record.world, xianxia_beat) assert scene_detail(jade.world_record.world, jade_beat, repeated=False) != scene_detail(xianxia.world_record.world, xianxia_beat, repeated=False) + + +def test_weakest_pack_sensory_and_scene_assets_have_multiple_variants(): + registry = FileSystemWorldRegistry() + for world_version_id in [ + "urban_mystery_lotus_lane@0.1.0", + "jade_court_romance@1.0.0", + "synthetic_min_pack@0.1.0", + ]: + runtime = registry.get_runtime_bundle(world_version_id) + sensory = (runtime.worldpack.sensory_grounding_policies or {}).get("default") or {} + location_slots = dict(sensory.get("location_slots") or {}) + assert location_slots + sample_slot = next(iter(location_slots.values())) + assert len(sample_slot.get("atmosphere", [])) >= 3 + assert len(sample_slot.get("detail", [])) >= 3 + assert len(sample_slot.get("repeat_detail", [])) >= 3 + + scene = (runtime.worldpack.scene_realization_contracts or {}).get("default") or {} + scene_openings = dict(scene.get("scene_openings") or {}) + scene_hooks = dict(scene.get("scene_hooks") or {}) + assert scene_openings + assert scene_hooks + first_opening = next(iter(scene_openings.values())) + first_hook = next(iter(scene_hooks.values())) + assert len(first_opening) >= 3 + assert len(first_hook) >= 3 + + +def test_detail_markers_count_concrete_archive_and_court_objects(): + text = "扫描台蓝线照到钝印和胶痕,栏杆旁的杯沿、木板和录音笔一起发出轻响。" + report = lint_prose(text) + + assert report["detail_count"] >= 8 + assert report["concrete_detail_density"] > 0.04 + + +def test_runtime_event_metadata_carries_scene_quality_contract(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("synthetic_min_pack@0.1.0") + first_event = runtime.event_atoms[0] + + contract = dict(first_event.metadata.get("scene_quality_contract") or {}) + assert contract["dialogue_pressure"] == "medium" + assert "object_state" in contract["detail_anchor_types"] + + +def test_synthetic_detail_reinforcement_uses_scene_contract_anchors(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("synthetic_min_pack@0.1.0") + beat = type("Beat", (), {"event": runtime.event_atoms[0], "dramatic_job": "entry", "beat_index": 1})() + + detail = scene_detail(runtime.world_record.world, beat, repeated=False, chapter_index=1) + + assert any(token in detail for token in ["石砖", "空杯", "纸页"]) + assert any(token in detail for token in ["门檐", "灯下", "潮气", "翻页声"]) + + +def test_synthetic_repeated_detail_varies_across_long_route_chapters(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("synthetic_min_pack@0.1.0") + beat = type("Beat", (), {"event": runtime.event_atoms[0], "dramatic_job": "pressure", "beat_index": 2})() + + samples = { + scene_detail(runtime.world_record.world, beat, repeated=True, chapter_index=chapter_index) + for chapter_index in [12, 39, 78] + } + + assert len(samples) >= 2 + assert all(lint_prose(sample)["concrete_detail_density"] >= 0.04 for sample in samples) + + +def test_target_pack_scene_details_use_concrete_anchor_density(): + registry = FileSystemWorldRegistry() + targets = [ + "tide_archive_memory_debt@0.1.0", + "jade_court_exam@1.0.0", + "jade_court_romance@1.0.0", + "urban_mystery_lotus_lane@0.1.0", + "xianxia_forgotten_vow@0.1.0", + ] + for world_version_id in targets: + runtime = registry.get_runtime_bundle(world_version_id) + event = next(item for item in runtime.event_atoms if item.location) + beat = type("Beat", (), {"event": event, "dramatic_job": "pressure", "beat_index": 2})() + + detail = scene_detail(runtime.world_record.world, beat, repeated=False, chapter_index=260) + report = lint_prose(detail) + + assert float(report["concrete_detail_density"]) >= 0.065 + assert any(token in detail for token in ["杯沿", "纸页", "灯座", "防潮盒", "雨棚", "石阶", "扫描台", "案角", "栏杆", "香炉", "门框", "笔架", "雨伞骨", "旧门牌"]) + assert any(token in detail for token in ["回声", "落笔声", "水滴声", "钟声", "电流声", "风声", "脚步声", "叶响", "衣袂", "翻卷声"]) diff --git a/tests/test_targeted_longform100_compare_script.py b/tests/test_targeted_longform100_compare_script.py new file mode 100644 index 0000000..fa70b3c --- /dev/null +++ b/tests/test_targeted_longform100_compare_script.py @@ -0,0 +1,65 @@ +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = ROOT / "scripts" / "run_targeted_longform100_compare.py" + + +def _load_script_module(): + spec = spec_from_file_location("targeted_longform100_compare", SCRIPT_PATH) + assert spec and spec.loader + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_targeted_longform100_compare_script_builds_delta_summary(): + module = _load_script_module() + summary = module.build_targeted_compare_summary( + before={ + "worlds": [ + { + "world_id": "urban_mystery_lotus_lane", + "world_version_id": "urban_mystery_lotus_lane@0.1.0", + "pass_rate": 0.7, + "rewrite_rate": 0.3, + "block_rate": 0.0, + "diagnostic_rank": 2, + "diagnostic_score": 0.12, + "top_issue_categories": [{"issue_code": "Q03", "count": 12}, {"issue_code": "Q09", "count": 4}], + "continuation_calibration": {"q03": {"recommendation": "insufficient_coverage"}, "q09": {"recommendation": "insufficient_coverage"}, "sample_count": 0, "sample_gap": 8}, + } + ] + }, + after={ + "worlds": [ + { + "world_id": "urban_mystery_lotus_lane", + "world_version_id": "urban_mystery_lotus_lane@0.1.0", + "pass_rate": 0.8, + "rewrite_rate": 0.2, + "block_rate": 0.0, + "diagnostic_rank": 1, + "diagnostic_score": 0.09, + "top_issue_categories": [{"issue_code": "Q03", "count": 8}, {"issue_code": "Q09", "count": 2}], + "continuation_calibration": {"q03": {"recommendation": "hold"}, "q09": {"recommendation": "tighten"}, "sample_count": 8, "sample_gap": 0}, + } + ] + }, + supplementation={ + "world_summaries": [ + { + "world_version_id": "urban_mystery_lotus_lane@0.1.0", + "after_signal_summary": {"sample_count": 8, "negative_count": 2}, + } + ] + }, + target_worlds=["urban_mystery_lotus_lane"], + ) + delta = summary["world_deltas"][0] + assert delta["before_q03_count"] == 12 + assert delta["after_q03_count"] == 8 + assert delta["before_calibration"]["q03_recommendation"] == "insufficient_coverage" + assert delta["after_calibration"]["q03_recommendation"] == "hold" + assert delta["after_calibration"]["q09_recommendation"] == "tighten" diff --git a/tests/test_tide_archive_worldpack.py b/tests/test_tide_archive_worldpack.py new file mode 100644 index 0000000..5c4bc32 --- /dev/null +++ b/tests/test_tide_archive_worldpack.py @@ -0,0 +1,140 @@ +from src.narrativeos.benchmark.runner import run_benchmark +from src.narrativeos.repository import SQLAlchemyRepository +from src.narrativeos.services.longform_capability import longform_structure_counts +from src.narrativeos.worldpacks.registry import FileSystemWorldRegistry +from src.narrativeos.worldpacks.validator import validate_worldpack_payload + + +def test_tide_archive_worldpack_is_benchmark_registered(): + registry = FileSystemWorldRegistry() + card = registry.get_published_world("tide_archive_memory_debt") + + assert card["catalog_role"] == "published" + assert card["benchmark_enabled"] is True + assert card["world_version_id"] == "tide_archive_memory_debt@0.1.0" + + +def test_tide_archive_worldpack_meets_longform_structure_contract(): + registry = FileSystemWorldRegistry() + payload = registry.get_published_world("tide_archive_memory_debt")["worldpack"] + validation = validate_worldpack_payload(payload) + counts = longform_structure_counts(payload) + + assert validation["ok"] is True + assert payload["series_plan"]["total_chapter_target"] == 100 + assert payload["series_plan"]["total_volume_target"] == 5 + assert len(payload["volume_plans"]) == 5 + assert len(payload["arc_plans"]) == 15 + assert counts["character_count"] == 10 + assert counts["scene_blueprint_count"] == 12 + assert counts["location_count"] == 8 + assert counts["scene_family_count"] >= 10 + assert counts["distinct_role_pair_count"] >= 12 + assert validation["content_quality_contract_coverage"]["ok"] is True + + +def test_tide_archive_worldpack_carries_benchmark_windows_and_thresholds(): + registry = FileSystemWorldRegistry() + payload = registry.get_published_world("tide_archive_memory_debt")["worldpack"] + benchmark_pack = payload["metadata"]["benchmark_test_pack"] + + assert benchmark_pack["route_families"] == ["真相优先", "关系优先", "生存优先", "权力优先"] + assert benchmark_pack["ending_families"] == ["公开揭露", "私下保全", "牺牲封口", "带罪共存"] + assert [item["window"] for item in benchmark_pack["manual_review_windows"]] == [ + "1-5", + "18-22", + "38-42", + "58-62", + "78-82", + "96-100", + ] + assert [item["chapter"] for item in benchmark_pack["interactive_scenarios"]] == [15, 33, 52] + assert benchmark_pack["acceptance_thresholds"]["voice_separation_score_min"] == 0.65 + + +def test_tide_archive_worldpack_applies_round_one_window_repairs(): + registry = FileSystemWorldRegistry() + payload = registry.get_published_world("tide_archive_memory_debt")["worldpack"] + + archive_anomaly = next(item for item in payload["scene_blueprints"] if item["scene_id"] == "archive_anomaly") + submerged_return = next(item for item in payload["scene_blueprints"] if item["scene_id"] == "submerged_return") + late_arc = next(item for item in payload["arc_plans"] if item["arc_id"] == "tide_archive_memory_debt::series::volume_5::arc_1") + late_task = next(item for item in late_arc["chapter_tasks"] if item["chapter_task_id"] == "tide_archive_memory_debt::series::volume_5::arc_1::task_2") + + assert archive_anomaly["quality_contract"]["dialogue_pressure"] == "high" + assert "information_reveal" in archive_anomaly["quality_contract"]["variation_axes"] + assert "object_state" in archive_anomaly["quality_contract"]["detail_anchor_types"] + assert archive_anomaly["beats_template"][0].startswith("闻汐从防潮盒里抽出") + + assert submerged_return["quality_contract"]["dialogue_pressure"] == "high" + assert "information_reveal" in submerged_return["quality_contract"]["variation_axes"] + assert "object_state" in submerged_return["quality_contract"]["detail_anchor_types"] + assert submerged_return["beats_template"][0].startswith("打捞箱开封") + + assert "next_chapter_hook_intensified" in late_arc["completion_conditions"] + assert late_task["quality_contract"]["delayed_payoff_window"] == {"min_chapters": 1, "max_chapters": 4} + assert late_task["promise_targets"] == ["tide_archive_memory_debt::series::volume_5::arc_1::promise_turn"] + assert "contract_q09_repair_window=late" in late_task["notes"] + assert "结尾必须把下一章问题推出去" in late_task["objective"] + + +def test_tide_archive_worldpack_applies_group_scene_realization_and_emotion_action_repairs(): + registry = FileSystemWorldRegistry() + payload = registry.get_published_world("tide_archive_memory_debt")["worldpack"] + + scene_realization = payload["scene_realization_contracts"]["default"] + emotion_actions = payload["emotion_action_policies"]["default"]["action_map"] + + assert len(scene_realization["scene_openings"]["false_peace"]) >= 3 + assert len(scene_realization["scene_hooks"]["false_peace"]) >= 3 + assert len(scene_realization["scene_openings"]["temptation"]) >= 3 + assert len(scene_realization["scene_hooks"]["temptation"]) >= 3 + assert len(scene_realization["scene_openings"]["truth_trial"]) >= 3 + assert len(scene_realization["scene_hooks"]["truth_trial"]) >= 3 + assert len(scene_realization["scene_openings"]["karma_ripening"]) >= 3 + assert len(scene_realization["scene_hooks"]["karma_ripening"]) >= 3 + assert "misrecognition" in scene_realization["scene_openings"] + assert "debt_exchange" in scene_realization["scene_hooks"] + + assert len(emotion_actions["false_peace"]["entry"]) >= 3 + assert len(emotion_actions["false_peace"]["pressure"]) >= 3 + assert len(emotion_actions["karma_ripening"]["pivot"]) >= 3 + assert len(emotion_actions["debt_exchange"]["echo"]) >= 3 + + xu_hui = payload["voice_profiles"]["xu_hui"] + song_wanqing = payload["voice_profiles"]["song_wanqing"] + assert len(xu_hui["opening_style"]) >= 3 + assert len(xu_hui["signature_replies"]) >= 3 + assert len(song_wanqing["pressure_style"]) >= 3 + assert len(song_wanqing["echo_style"]) >= 3 + + +def test_tide_archive_worldpack_standard_benchmark_smoke(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "tide_archive_benchmark.db")) + + report = run_benchmark( + repository=repository, + golden_dir=tmp_path / "goldens", + worldpack="tide_archive_memory_debt", + ) + + world = report["worlds"][0] + assert world["world_id"] == "tide_archive_memory_debt" + assert world["completion_ratio"] == 1.0 + assert world["stop_reason"] == "chapter_budget_reached" + assert world["voice_separation_score"] >= 0.65 + assert report["phase_a_quality_gate"]["ok"] is True + issue_codes = {item["issue_code"] for item in world["top_issue_categories"]} + assert "Q03" not in issue_codes + assert "Q04" not in issue_codes + + +def test_tide_runtime_bundle_preserves_character_id_required_roles(): + registry = FileSystemWorldRegistry() + runtime = registry.get_runtime_bundle("tide_archive_memory_debt@0.1.0") + + first_event = runtime.event_atoms[0] + second_event = runtime.event_atoms[1] + + assert first_event.actors == ["wen_xi", "gu_chenzhou"] + assert second_event.actors == ["wen_xi", "gu_chenzhou"]